mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [431e73b7] Wire the real-spawn path: OrchestratorStageSpawner + disposable MCP config (#701)
* [431e73b7] Wire the eval harness real-spawn path: OrchestratorStageSpawner + disposable MCP config
_generate_mcp_config now prefers settings.api_url when set (both
PROJECT_HOST_PATH branches), so spawned MCP servers resolve to the
harness's disposable orchestrator URL instead of the real production
hostname or 127.0.0.1:port. OrchestratorStageSpawner.__init__ replaces
the NotImplementedError with a real AgentOrchestrator() constructed the
same way the production dispatcher builds it. The runner module
docstring + __main__.py docstring/run-subparser help drop the
NOT-YET-FUNCTIONAL wording. A new unit test pins the no-production-reach
guarantee: with settings.api_url patched, the MCP config's
ROBOCO_API_URL/ROBOCO_ORCHESTRATOR_URL point at the disposable URL (not
production), and the agent UUID is the real fixed UUID from
foundation.identity.AGENTS.
* [431e73b7] docs(eval): reflect the wired real-spawn path in tests map, CLAUDE.md, and CHANGELOG
---------
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
* [5cc75f71] Fix disposable orchestrator container-reachability + document real-UUID isolation design (#705)
* [5cc75f71] Fix disposable orchestrator container-reachability + document real-UUID isolation
* [5cc75f71] docs(eval-harness): document container-reachability fix + real-UUID isolation in map docs
---------
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
* [d96ec059] Rewrite stale eval-spawner pinning test to assert wired behavior (test-only, CI-green for PR #703) (#707) (#708)
* [d96ec059] test(eval): assert OrchestratorStageSpawner constructs a real AgentOrchestrator
Rewrite the stale pinning test that asserted the PRE-wiring
NotImplementedError (removed by commit 488e9e2f when the real-spawn
path was wired). The test now asserts the wired behavior:
OrchestratorStageSpawner() construction succeeds, _orchestrator is
an AgentOrchestrator instance, and _stage_timeout_seconds defaults
to 900.0. Renamed from test_orchestrator_stage_spawner_is_cut_and_
refuses_to_construct to reflect the new contract. Test-only — no
production code touched.
* [d96ec059] docs(map): note test_scoring spawner pinning test in eval-harness map entry
Add one clause to docs/map/tests.md's roboco/eval/ row naming
tests/unit/eval/test_scoring.py::test_orchestrator_stage_spawner_constructs_real_orchestrator
as the unit test that pins the OrchestratorStageSpawner wired-construction
contract (construction succeeds, _orchestrator is an AgentOrchestrator,
_stage_timeout_seconds defaults to 900.0). Consistent with the line's
existing pattern of citing test_eval_mcp_config_isolation.py and
test_eval_bench.py by name for the contracts they pin. The CHANGELOG #701
entry already covers the user-visible wiring; no CHANGELOG change needed.
---------
Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
---------
Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend PM <be-pm@roboco.tech>
565 lines
22 KiB
Python
565 lines
22 KiB
Python
"""e2e smoke harness — in-process RoboCo stack + scripted-agent driver.
|
|
|
|
Pieces (all REAL except GitHub and the LLM):
|
|
|
|
- The API: the real v1 flow/do routers + real middleware/exception handlers,
|
|
served by uvicorn in a thread, over the ephemeral test Postgres (the app's
|
|
own lazy engine is pointed at it by patching ``settings.database_*`` and
|
|
resetting ``_DbHolder``).
|
|
- Git: a local bare origin whose path CONTAINS ``github.com/<owner>/<repo>``
|
|
— ``_parse_git_url`` extracts owner/repo from it while clone/fetch/push
|
|
run tokenless over the local protocol.
|
|
- GitHub REST: a fake ``/_github`` router mounted on the same app
|
|
(``settings.github_api_base_url`` points at it). PR state lives in memory;
|
|
merges perform REAL git merges (squash included) on the bare origin, so
|
|
downstream git logic (cherry checks, freshness, branch sync) sees reality.
|
|
- Agents: ``ScriptedAgent`` reloads the REAL ``roboco.mcp.flow_server`` /
|
|
``do_server`` modules with that agent's env (id, role, role-scoped
|
|
manifest built from the real ``role_config``) and calls the REAL tool
|
|
functions, which POST to the in-process API over loopback HTTP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
import uvicorn
|
|
from cryptography.fernet import Fernet
|
|
from fastapi import APIRouter, FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.engine.url import make_url
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from typing import Protocol
|
|
from uuid import UUID
|
|
|
|
class TmpPathFactory(Protocol):
|
|
"""Structural stand-in for the one ``pytest.TempPathFactory`` method
|
|
``build_e2e_stack`` uses. pytest's real fixture value already
|
|
satisfies this shape, so it needs no adapter — but it lets
|
|
``roboco/eval/runner.py`` (an offline CLI, not a pytest session) drive
|
|
this same stack-building machinery with a plain temp-dir factory
|
|
instead of constructing a real ``pytest.Config``."""
|
|
|
|
def mktemp(self, basename: str, numbered: bool = True) -> Path: ...
|
|
|
|
|
|
_OWNER = "e2e-smoke"
|
|
_REPO = "proj"
|
|
|
|
|
|
def _git(cwd: Path, *args: str) -> str:
|
|
res = subprocess.run(
|
|
["git", "-C", str(cwd), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return res.stdout.strip()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake GitHub REST — PR state in memory, merges as REAL git ops on the origin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _FakeGitHub:
|
|
origin: Path
|
|
admin_clone: Path
|
|
prs: dict[int, dict[str, Any]] = field(default_factory=dict)
|
|
comments: list[dict[str, Any]] = field(default_factory=list)
|
|
next_number: int = 1
|
|
|
|
def create_pr(self, title: str, body: str, head: str, base: str) -> dict[str, Any]:
|
|
number = self.next_number
|
|
self.next_number += 1
|
|
pr = {
|
|
"number": number,
|
|
"html_url": f"https://github.com/{_OWNER}/{_REPO}/pull/{number}",
|
|
"title": title,
|
|
"body": body,
|
|
"state": "open",
|
|
"merged": False,
|
|
"head": {
|
|
"ref": head,
|
|
"sha": self._sha_of(head),
|
|
"repo": {"full_name": f"{_OWNER}/{_REPO}"},
|
|
},
|
|
"base": {"ref": base},
|
|
"user": {"login": "e2e-bot"},
|
|
"author_association": "MEMBER",
|
|
}
|
|
self.prs[number] = pr
|
|
return pr
|
|
|
|
def _sha_of(self, branch: str) -> str:
|
|
try:
|
|
return _git(self.origin, "rev-parse", branch)
|
|
except subprocess.CalledProcessError:
|
|
return "0" * 40
|
|
|
|
def merge_pr(self, number: int, merge_method: str) -> dict[str, Any]:
|
|
pr = self.prs[number]
|
|
head, base = pr["head"]["ref"], pr["base"]["ref"]
|
|
admin = self.admin_clone
|
|
_git(admin, "fetch", "origin", "--prune")
|
|
_git(admin, "checkout", "-B", base, f"origin/{base}")
|
|
if merge_method == "squash":
|
|
_git(admin, "merge", "--squash", f"origin/{head}")
|
|
_git(admin, "commit", "-m", f"{pr['title']} (#{number})")
|
|
else:
|
|
_git(
|
|
admin,
|
|
"merge",
|
|
"--no-ff",
|
|
"-m",
|
|
f"Merge pull request #{number} from {head}",
|
|
f"origin/{head}",
|
|
)
|
|
_git(admin, "push", "origin", base)
|
|
sha = _git(admin, "rev-parse", "HEAD")
|
|
pr["merged"] = True
|
|
pr["state"] = "closed"
|
|
return {
|
|
"merged": True,
|
|
"sha": sha,
|
|
"message": "Pull Request successfully merged",
|
|
}
|
|
|
|
def open_prs(self, head: str | None, base: str | None) -> list[dict[str, Any]]:
|
|
out = []
|
|
for pr in self.prs.values():
|
|
if pr["state"] != "open":
|
|
continue
|
|
if head and pr["head"]["ref"] != head.split(":", 1)[-1]:
|
|
continue
|
|
if base and pr["base"]["ref"] != base:
|
|
continue
|
|
out.append(pr)
|
|
return out
|
|
|
|
|
|
def _fake_github_router(gh: _FakeGitHub) -> APIRouter:
|
|
r = APIRouter(prefix="/_github")
|
|
|
|
@r.get("/repos/{owner}/{repo}")
|
|
async def repo_caps(owner: str, repo: str) -> dict[str, Any]:
|
|
return {
|
|
"allow_squash_merge": True,
|
|
"allow_merge_commit": True,
|
|
"allow_rebase_merge": False,
|
|
}
|
|
|
|
@r.get("/repos/{owner}/{repo}/pulls/{number}")
|
|
async def get_pr(owner: str, repo: str, number: int) -> JSONResponse:
|
|
pr = gh.prs.get(number)
|
|
if pr is None:
|
|
return JSONResponse({"message": "Not Found"}, status_code=404)
|
|
# Real GitHub recomputes head.sha as the branch advances; a stale
|
|
# creation-time snapshot broke the unchanged-PR gate's semantics.
|
|
pr["head"]["sha"] = gh._sha_of(pr["head"]["ref"])
|
|
return JSONResponse(pr)
|
|
|
|
@r.get("/repos/{owner}/{repo}/pulls")
|
|
async def list_prs(
|
|
owner: str,
|
|
repo: str,
|
|
head: str | None = None,
|
|
base: str | None = None,
|
|
state: str = "open",
|
|
) -> list[dict[str, Any]]:
|
|
return gh.open_prs(head, base)
|
|
|
|
@r.post("/repos/{owner}/{repo}/pulls", status_code=201)
|
|
async def create_pr(owner: str, repo: str, request: Request) -> dict[str, Any]:
|
|
body = await request.json()
|
|
return gh.create_pr(
|
|
body["title"], body.get("body", ""), body["head"], body["base"]
|
|
)
|
|
|
|
@r.patch("/repos/{owner}/{repo}/pulls/{number}")
|
|
async def patch_pr(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> JSONResponse:
|
|
pr = gh.prs.get(number)
|
|
if pr is None:
|
|
return JSONResponse({"message": "Not Found"}, status_code=404)
|
|
body = await request.json()
|
|
for key in ("title", "body", "state"):
|
|
if key in body:
|
|
pr[key] = body[key]
|
|
return JSONResponse(pr)
|
|
|
|
@r.put("/repos/{owner}/{repo}/pulls/{number}/merge")
|
|
async def merge_pr(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> JSONResponse:
|
|
if number not in gh.prs:
|
|
return JSONResponse({"message": "Not Found"}, status_code=404)
|
|
body = await request.json()
|
|
try:
|
|
result = gh.merge_pr(number, body.get("merge_method", "merge"))
|
|
except subprocess.CalledProcessError as exc:
|
|
return JSONResponse(
|
|
{"message": f"Merge conflict: {exc.stderr}"}, status_code=409
|
|
)
|
|
return JSONResponse(result)
|
|
|
|
@r.post("/repos/{owner}/{repo}/pulls/{number}/requested_reviewers", status_code=201)
|
|
async def request_reviewers(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> dict[str, Any]:
|
|
return gh.prs.get(number, {})
|
|
|
|
@r.post("/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
|
|
async def comment(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> dict[str, Any]:
|
|
gh.comments.append({"number": number, "body": (await request.json())})
|
|
return {"id": len(gh.comments)}
|
|
|
|
@r.delete("/repos/{owner}/{repo}/git/refs/heads/{branch:path}", status_code=204)
|
|
async def delete_branch(owner: str, repo: str, branch: str) -> None:
|
|
with suppress(subprocess.CalledProcessError):
|
|
_git(gh.origin, "branch", "-D", branch)
|
|
|
|
# A minimal green check-runs signal for any commit — real-life NAS
|
|
# projects have CI, so the pr_pass CI-status guard should see a
|
|
# `success` state and exercise its pass-through branch, not the
|
|
# no_ci_configured 404 path.
|
|
@r.get("/repos/{owner}/{repo}/commits/{sha}/check-runs")
|
|
async def check_runs(owner: str, repo: str, sha: str) -> dict[str, Any]:
|
|
return {
|
|
"total_count": 1,
|
|
"check_runs": [
|
|
{"name": "ci", "status": "completed", "conclusion": "success"}
|
|
],
|
|
}
|
|
|
|
@r.get("/repos/{owner}/{repo}/actions/workflows")
|
|
async def workflows(owner: str, repo: str) -> dict[str, Any]:
|
|
return {"total_count": 1, "workflows": [{"id": 1, "name": "ci"}]}
|
|
|
|
return r
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stack: settings patches + origin + app + uvicorn thread
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class E2EStack:
|
|
base_url: str
|
|
root: Path
|
|
origin: Path
|
|
workspaces_root: Path
|
|
db_url: str
|
|
github: _FakeGitHub
|
|
# URL spawned agent containers use to reach this in-process orchestrator.
|
|
# base_url stays 127.0.0.1 (host-side test clients); container_url is
|
|
# host.docker.internal so containers on the roboco_default bridge can
|
|
# resolve the host's published port (the server binds 0.0.0.0 to accept
|
|
# both). Set by build_e2e_stack alongside base_url.
|
|
container_url: str = ""
|
|
|
|
def workspace_of(self, project_slug: str, team: str, agent_slug: str) -> Path:
|
|
return self.workspaces_root / project_slug / team / agent_slug
|
|
|
|
def run_db(self, coro_fn: Any) -> Any:
|
|
"""Run ``coro_fn(session)`` against a fresh engine/session and return."""
|
|
|
|
async def _run() -> Any:
|
|
engine = create_async_engine(self.db_url)
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
try:
|
|
async with factory() as session:
|
|
result = await coro_fn(session)
|
|
await session.commit()
|
|
return result
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return int(s.getsockname()[1])
|
|
|
|
|
|
def _seed_origin(root: Path) -> Path:
|
|
"""Bare origin at a path _parse_git_url can read owner/repo from."""
|
|
origin = root / "github.com" / _OWNER / f"{_REPO}.git"
|
|
origin.parent.mkdir(parents=True)
|
|
subprocess.run(
|
|
["git", "init", "--bare", "--initial-branch=master", str(origin)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
seed = root / "seed-clone"
|
|
subprocess.run(
|
|
["git", "clone", str(origin), str(seed)], check=True, capture_output=True
|
|
)
|
|
_git(seed, "config", "user.name", "roboco-e2e")
|
|
_git(seed, "config", "user.email", "e2e@roboco.local")
|
|
(seed / "README.md").write_text("# e2e smoke project\n")
|
|
_git(seed, "add", "README.md")
|
|
_git(seed, "commit", "-m", "Initial commit")
|
|
_git(seed, "push", "origin", "master")
|
|
return origin
|
|
|
|
|
|
def _make_admin_clone(root: Path, origin: Path) -> Path:
|
|
admin = root / "gh-admin-clone"
|
|
subprocess.run(
|
|
["git", "clone", str(origin), str(admin)], check=True, capture_output=True
|
|
)
|
|
_git(admin, "config", "user.name", "fake-github")
|
|
_git(admin, "config", "user.email", "merge@github.local")
|
|
return admin
|
|
|
|
|
|
def _build_app(gh: _FakeGitHub) -> FastAPI:
|
|
from roboco.api.middleware import setup_middleware
|
|
from roboco.api.routes.dashboard import router as dashboard_router
|
|
from roboco.api.routes.health import router as health_router
|
|
from roboco.api.routes.notifications import router as notifications_router
|
|
from roboco.api.routes.orchestrator import router as orchestrator_router
|
|
from roboco.api.routes.settings import router as settings_router
|
|
from roboco.api.routes.tasks import router as tasks_router
|
|
from roboco.api.routes.v1 import do as do_module
|
|
from roboco.api.routes.v1 import flow_auditor as fa
|
|
from roboco.api.routes.v1 import flow_board as fb
|
|
from roboco.api.routes.v1 import flow_cell_pm as fcp
|
|
from roboco.api.routes.v1 import flow_dev as fd
|
|
from roboco.api.routes.v1 import flow_doc as fdoc
|
|
from roboco.api.routes.v1 import flow_main_pm as fmp
|
|
from roboco.api.routes.v1 import flow_pr_reviewer as fpr
|
|
from roboco.api.routes.v1 import flow_qa as fq
|
|
|
|
app = FastAPI(title="roboco-e2e-smoke")
|
|
setup_middleware(app)
|
|
app.include_router(health_router)
|
|
for module in (fd, fq, fdoc, fcp, fmp, fb, fa, fpr):
|
|
app.include_router(module.router)
|
|
app.include_router(do_module.router)
|
|
# The REST task surface — scenario 3 drives the real CEO
|
|
# approve-and-merge endpoint (the human gate) through it.
|
|
app.include_router(tasks_router, prefix="/api/tasks")
|
|
# The notifications router was already mounted here before the auditor
|
|
# revival diff; it remains so reactive audit dispatch (``_dispatch_audit_work``)
|
|
# can poll real ALERT rows end-to-end.
|
|
app.include_router(notifications_router, prefix="/api/notifications")
|
|
# Cloud-auth gate coverage smoke exercises the real _require_ceo and
|
|
# require_panel_token dep paths on these routers.
|
|
app.include_router(orchestrator_router, prefix="/api/orchestrator")
|
|
app.include_router(settings_router, prefix="/api/settings")
|
|
app.include_router(dashboard_router, prefix="/api/dashboard")
|
|
app.include_router(_fake_github_router(gh))
|
|
return app
|
|
|
|
|
|
def build_e2e_stack(
|
|
_test_database_url: str, tmp_path_factory: TmpPathFactory
|
|
) -> Iterator[E2EStack]:
|
|
"""Generator behind the ``e2e_stack`` fixture (defined in conftest).
|
|
|
|
``tmp_path_factory`` only needs ``.mktemp()`` (see ``TmpPathFactory``
|
|
above) — pytest's real fixture satisfies it structurally, and
|
|
``roboco/eval/runner.py`` drives this same function with a plain
|
|
non-pytest factory to reuse this stack outside a test session.
|
|
"""
|
|
from roboco.config import settings
|
|
from roboco.db import base as db_base
|
|
|
|
mp = pytest.MonkeyPatch()
|
|
root = tmp_path_factory.mktemp("e2e")
|
|
origin = _seed_origin(root)
|
|
admin = _make_admin_clone(root, origin)
|
|
gh = _FakeGitHub(origin=origin, admin_clone=admin)
|
|
workspaces = root / "workspaces"
|
|
workspaces.mkdir()
|
|
|
|
url = make_url(_test_database_url)
|
|
mp.setattr(settings, "database_host", url.host or "localhost")
|
|
mp.setattr(settings, "database_port", url.port or 5432)
|
|
mp.setattr(settings, "database_user", url.username or "")
|
|
mp.setattr(settings, "database_password", url.password or "")
|
|
mp.setattr(settings, "database_name", url.database or "")
|
|
mp.setattr(settings, "workspaces_root", str(workspaces))
|
|
mp.setattr(settings, "workspace_auto_clone", True)
|
|
mp.setattr(settings, "encryption_key", Fernet.generate_key().decode())
|
|
|
|
# The app's lazy engine must bind to the patched settings, not a leftover.
|
|
db_base._DbHolder.engine = None
|
|
db_base._DbHolder.session_factory = None
|
|
|
|
port = _free_port()
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
mp.setattr(settings, "github_api_base_url", f"{base_url}/_github")
|
|
|
|
app = _build_app(gh)
|
|
# loop=settings.uvicorn_loop ("asyncio" by default): uvicorn auto-selects
|
|
# uvloop when installed, and this in-thread server has crashed CI with a
|
|
# uvloop/asyncpg segfault (uvloop 0.22 + asyncpg 0.31 + Python 3.13) —
|
|
# mirror the production default instead of picking up uvloop implicitly.
|
|
# Bind 0.0.0.0 so spawned agent containers on the roboco_default bridge
|
|
# can reach the server via host.docker.internal (host-gateway). Host-side
|
|
# clients still connect via 127.0.0.1 (base_url) — 0.0.0.0 accepts both.
|
|
server = uvicorn.Server(
|
|
uvicorn.Config(
|
|
app,
|
|
host="0.0.0.0",
|
|
port=port,
|
|
log_level="warning",
|
|
loop=settings.uvicorn_loop,
|
|
)
|
|
)
|
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
|
|
import httpx
|
|
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
try:
|
|
# Any HTTP response at all means the server thread is up.
|
|
httpx.get(f"{base_url}/health", timeout=1)
|
|
break
|
|
except httpx.HTTPError:
|
|
time.sleep(0.1)
|
|
else:
|
|
raise RuntimeError("e2e app server did not become ready")
|
|
|
|
try:
|
|
yield E2EStack(
|
|
base_url=base_url,
|
|
root=root,
|
|
origin=origin,
|
|
workspaces_root=workspaces,
|
|
db_url=_test_database_url,
|
|
github=gh,
|
|
container_url=f"http://host.docker.internal:{port}",
|
|
)
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=10)
|
|
db_base._DbHolder.engine = None
|
|
db_base._DbHolder.session_factory = None
|
|
mp.undo()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scripted agents — the REAL MCP tool functions, per-agent module reloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ScriptedAgent:
|
|
"""Drives the real flow/do MCP tool functions as one seeded agent."""
|
|
|
|
def __init__(self, stack: E2EStack, agent_id: UUID, slug: str, role: str) -> None:
|
|
self.stack = stack
|
|
self.agent_id = agent_id
|
|
self.slug = slug
|
|
self.role = role
|
|
self._manifest_path = stack.root / f"manifest-{slug}.json"
|
|
self._manifest_path.write_text(json.dumps(self._manifest()))
|
|
|
|
def _manifest(self) -> dict[str, Any]:
|
|
from roboco.services.gateway.role_config import get_role_config
|
|
|
|
cfg = get_role_config(self.role)
|
|
return {
|
|
"agent_id": str(self.agent_id),
|
|
"role": self.role,
|
|
"team": "backend",
|
|
"workspace_path": str(self.stack.workspaces_root),
|
|
"flow_tools": list(cfg.flow_tools),
|
|
"do_tools": list(cfg.do_tools),
|
|
"read_tools": ["Read", "Glob", "Grep"],
|
|
"write_tools": ["Edit", "Write"] if cfg.allows_write else [],
|
|
"bash_allowed": True,
|
|
"subagent_allowed": False,
|
|
"subagent_model": None,
|
|
"env": {},
|
|
}
|
|
|
|
def _module(self, name: str) -> ModuleType:
|
|
os.environ["ROBOCO_AGENT_ID"] = str(self.agent_id)
|
|
os.environ["ROBOCO_AGENT_ROLE"] = self.role
|
|
os.environ["ROBOCO_ORCHESTRATOR_URL"] = self.stack.base_url
|
|
os.environ["ROBOCO_TOOL_MANIFEST_PATH"] = str(self._manifest_path)
|
|
# The host agent environment may carry a real ROBOCO_AGENT_TOKEN issued
|
|
# for the test runner's identity. flow_server reads it before each call
|
|
# and forwards it in X-Agent-Token; the token won't match the ephemeral
|
|
# test agent IDs and causes 401s. Drop it so tests run in the same
|
|
# unsigned-token mode as CI.
|
|
os.environ.pop("ROBOCO_AGENT_TOKEN", None)
|
|
# Same leakage class for the per-verb circuit breaker: flow_server /
|
|
# do_server post rejections to ROBOCO_SDK_URL (default
|
|
# http://localhost:9000), which is this repo's own live agent SDK
|
|
# loopback port when the suite happens to run inside a real spawned
|
|
# agent container. That breaker then records genuine attempts for
|
|
# the ephemeral test-agent IDs and can trip circuit_open mid-test
|
|
# (surfaced by test_request_sandbox_guard_chain_over_real_api's 3
|
|
# deliberately-rejected calls). Point it at a guaranteed-refused
|
|
# loopback port so every environment sees the same fail-open
|
|
# bypass CI gets (no SDK process listening at all).
|
|
os.environ["ROBOCO_SDK_URL"] = "http://127.0.0.1:1"
|
|
module = importlib.import_module(name)
|
|
if getattr(module, "AGENT_ID", None) != str(self.agent_id):
|
|
module = importlib.reload(module)
|
|
return module
|
|
|
|
def flow(self, verb: str, /, **kwargs: Any) -> dict[str, Any]:
|
|
result: dict[str, Any] = getattr(self._module("roboco.mcp.flow_server"), verb)(
|
|
**kwargs
|
|
)
|
|
return result
|
|
|
|
def do(self, tool: str, /, **kwargs: Any) -> dict[str, Any]:
|
|
result: dict[str, Any] = getattr(self._module("roboco.mcp.do_server"), tool)(
|
|
**kwargs
|
|
)
|
|
return result
|
|
|
|
|
|
def expect_error(env: dict[str, Any], kind: str, context: str) -> dict[str, Any]:
|
|
"""Assert an envelope is the EXPECTED rejection kind."""
|
|
assert env.get("error") == kind, (
|
|
f"{context}: expected rejection {kind!r}, got error={env.get('error')!r}\n"
|
|
f" full: {json.dumps(env, default=str, indent=2)[:4000]}"
|
|
)
|
|
return env
|
|
|
|
|
|
def expect_ok(env: dict[str, Any], context: str) -> dict[str, Any]:
|
|
"""Assert an envelope is a success; on failure show the whole envelope."""
|
|
assert isinstance(env, dict), f"{context}: non-dict envelope: {env!r}"
|
|
assert not env.get("error"), (
|
|
f"{context}: rejected with error={env.get('error')!r}\n"
|
|
f" message : {env.get('message')}\n"
|
|
f" remediate: {env.get('remediate')}\n"
|
|
f" missing : {env.get('missing')}\n"
|
|
f" full : {json.dumps(env, default=str, indent=2)[:4000]}"
|
|
)
|
|
return env
|