v0.17.0 — Wave 3: sandbox DB, DB isolation, mobile UI, cloud auth, X account, roadmap engine (#303)

* feat(sandbox): throwaway per-agent Postgres/Redis sandbox containers

Orchestrator-provisioned sibling containers per agent spawn
(SandboxProvisioner, roboco/runtime/sandbox.py). Per-project opt-in via
projects.sandbox_services (migration 057); master switch
ROBOCO_SANDBOX_DB_ENABLED, default-off, armed in the NAS compose only.

When active, ROBOCO_TEST_DB_* / ROBOCO_TEST_REDIS_* point at the sandbox
and the prod-creds gate-env injection is suppressed (sandbox replaces,
never coexists). Sandbox lifetime tracks the agent container: teardown at
every removal path, orphan janitor at startup + each reaper tick with a
grace window for mid-flight spawns. The pre-spawn stale-clear spares the
just-provisioned sandbox; provision pre-clears stale same-named
containers from a crash-missed teardown.

Panel: per-project sandbox-service switches in the edit dialog + feature
flag card entry.

* docs: CLAUDE.md entry for the sandboxed dev DB/Redis subsystem

* feat(security): isolate prod Postgres/Redis from agent containers (roboco_data network)

Second user-defined bridge roboco_data carries postgres+redis only; the
orchestrator is multi-homed (default + data). Spawned agents and their
sandbox sidecars stay on roboco_default and can no longer resolve or
reach roboco-postgres:5432 / roboco-redis:6379 (redis has no auth —
membership is its only containment). Normal bridge, so host-published
ports (15432/16379) keep working. Applied to both build composes and
the registry compose; docker-compose.yml re-synced byte-identical with
docker-compose.yaml (it had drifted by the sandbox flag block).

ROBOCO_DB_NETWORK_ISOLATED (config default false, armed alongside the
topology) suppresses the legacy _append_gate_env prod-creds injection:
under isolation those creds dead-end, and unreachable creds are worse
than none. DB-needing projects opt into sandbox_services instead. The
flag is deliberately not a panel feature flag - it must travel with the
compose networks: stanzas.

Preserved by construction: agent<->agent A2A and orchestrator->agent SDK
polls on :9000, MCP->orchestrator on :8000, ollama reachability, docker
exec/inspect (daemon socket), host port publishing.

* feat(panel): full mobile responsiveness pass

Shared primitives: useIsMobile (useSyncExternalStore, hydration-safe,
memoized matchMedia subscribe), ResponsiveTable table->card switch below
md (single subtree mounted, no duplicated interactive rows), scrollable
snap TabsList in the base primitive (justify-center-safe so the first
tab stays reachable on overflow), persistent md:hidden bottom tab bar
(Overview/Tasks/Kanban/Chat, safe-area padded).

Applied: card lists for tasks/projects/products/work-sessions/sessions
+ the three raw metrics tables; CEO approval queue / release proposal /
playbook review action rows stack on narrow; command-center reorders
approvals above the fold on mobile; task-header metadata wraps;
Communications + A2A become URL-driven single-pane drill-downs below lg
(fixes the unconstrained-height ScrollArea bug) with dvh heights;
recharts label density/radius adapts via useIsMobile; git diff viewer
gets mobile font + wrap toggle; vh->dvh sweep; chat composers get
safe-area-inset padding; dashboard main p-4 md:p-6 + pb-20 for the bar.

Verified at 375px on the built app: bottom bar, drawer, approval-first
overview, swipeable kanban tab strip. All gates green (eslint, tsc,
vitest 249, next build 24/24 routes).

* feat(auth): cloud auth via FastAPI Users (default-off, single-user cookie session)

ROBOCO_CLOUD_AUTH_ENABLED (default off) lets the panel/API be exposed
beyond localhost without changing the CEO's local no-login flow while
off — get_agent_context and the WS gate are byte-for-byte unchanged in
off-mode. On: header-trust dies for humans — any agent-role claim (ceo
or a privileged PM/board role) with no valid HMAC token or session
cookie is 401, closing the header-spoof hole on the host-published
:8000 port for every role. The agent-fleet HMAC path and the system
self-PATCH keep working unmodified in both modes.

Single seeded CEO user (migration 058 users table, UserTable), no
registration router — idempotent env-driven upsert at startup by PK.
Cookie transport (httponly/secure/samesite=lax) + a JWTStrategy bound
to a fingerprint of the current password hash (rotating the password
invalidates every prior session). Sliding 30-day session: every
authenticated request re-mints the cookie, so an active session never
expires — no unexpected logouts.

Panel: (auth)/login page + proxy.ts (Next 16 rename of middleware; probes
/auth/status over the docker-internal URL, fails open to off) gate the
dashboard; client.ts gets withCredentials + 401->/login. nginx unchanged.

Review hardening: broadened the on-mode rejection from ceo-only to every
non-CEO role without a valid token (was only closed when
ROBOCO_AGENT_AUTH_REQUIRED was also armed); Next-16 proxy.ts rename to
clear the middleware deprecation warning.

* feat(x): RoboCo X account engine — HoM drafts, per-post CEO approval (default-off)

ROBOCO_X_ENGINE_ENABLED (default off, inert without creds). Mirrors the
ReleaseManagerEngine held-artifact shape: XEngine drafts a post when a
release publishes (via a draft_release_post seam on ReleaseProposalService
.approve) and drafts replies to meaningful mentions (dedicated poll loop,
x_seen_mentions dedup ledger, per-cycle/open caps). Drafting is
local-model-only, clamped to 280 chars. Nothing auto-posts — every tweet
is a held task (source x_post/x_reply, confirmed_by_human=False,
Secretary-owned, dispatcher-skipped) the CEO edits/approves/rejects in a
panel queue.

The four OAuth 1.0a secrets live Fernet-encrypted in a singleton
x_credentials row (migration 059, all-or-nothing, API returns only
has_credentials); decryption is server-side, agents never hold creds or
egress. Hand-rolled OAuth 1.0a HMAC-SHA1 signer, no new dependency;
NullXClient makes the unconfigured path a graceful no-op.

XPostService.approve (CEO-only) is the sole caller of post_tweet.

Review hardening: closed a double-post race — the approve path now
re-reads committed task state inside the Redis lock and commits COMPLETED
before releasing, so a concurrent approve that acquires the lock after the
winner released can't re-post (SET-NX is non-waiting, and the route-level
commit landed after the lock dropped). Added a regression test.

* feat(roadmap): board roadmap engine — PO proposes themed cycles, CEO approves per-item (default-off)

ROBOCO_ROADMAP_ENGINE_ENABLED (default off). Weekly, RoadmapEngine opens
ONE held exploration task (source=board_roadmap, confirmed_by_human=False,
Product-Owner-assigned), deduped to one open cycle. A dedicated one-shot
_dispatch_roadmap_exploration spawns the PO solo (not the two-reviewer
board path, which would also spawn HoM + fire Approve-&-Start). The PO
explores read-only (git/KB/metrics/releases/charter/web) and makes one
propose_roadmap call (PO-only content verb) authoring a themed cycle —
goal + 3-7 item drafts — persisted as a roadmap_cycle marker (no table,
no migration; head stays 059).

The CEO acts per-item in the panel roadmap queue: approve materializes a
BACKLOG task (source=roadmap, no assignee — never auto-starts), reject
records a reason; all-items-terminal completes the exploration task.
RoadmapService is idempotent per item. Dispatchers skip board_roadmap.

Includes a real SQLAlchemy dirty-check fix (deep-copy the JSON marker
before mutating, or the in-place edit + reassign compares equal to its
own baseline and the UPDATE is skipped).

Review hardening: create_task_from_draft now honors a draft-declared
source only from a {prompter, roadmap} whitelist — drafts are
LLM-authored, so an unbounded source could impersonate a privileged
origin (release_manager would even wedge that engine's dedup).

* chore(release): 0.17.0

Wave 3 — six default-off subsystems: sandboxed dev DB/Redis, prod
Postgres/Redis network isolation, full mobile UI pass, cloud auth
(FastAPI Users), the RoboCo X account engine, and the board roadmap
engine. Plus the waves 1+2 work already on master since 0.16.0.

Version bumped across the canonical set (config.py, __init__.py,
pyproject.toml, panel/package.json, uv.lock); CHANGELOG [Unreleased]
cut to [0.17.0]; docs/map delta added.

Compose: every optional feature armed :-true in the NAS composes, OFF
in the user-facing registry compose. Two opt-in exceptions default off
(CLOUD_AUTH — needs email/password/secret + TLS, would otherwise fail
startup; ROUTING_STRICT — fail-closed spawning). DB_NETWORK_ISOLATED
stays on in both (coupled to the roboco_data topology).

* chore(compose): arm cloud_auth + routing_strict ON in the NAS composes

Every feature defaults ON in the NAS composes per policy — these two
were wrongly left off. Both keep the ${VAR:-true} form so the operator
controls the real runtime via .env: cloud auth needs
ROBOCO_CLOUD_AUTH_EMAIL/_PASSWORD/_SECRET + TLS set there before a boot
(else startup fails loud), and routing_strict is fail-closed. Registry
compose keeps both off.

* fix(ci): reflow board.md prose (quality gate) + document v0.17.0 env creds

The roadmap section added hard-wrapped prose that failed the markdown
prose gate; reflowed (token-invariant). Also brought .env.example
current: cloud auth (now armed — needs SECRET or startup fails), routing
strict, the X engine (panel-entered OAuth), and web research.

* fix(ci): reduce cyclomatic complexity of five wave-3 blocks (xenon gate)

The wave-3 subagents introduced C-rank functions the CI xenon gate
rejects (my per-item reviews ran ruff/mypy/pytest but not xenon):
- sandbox.janitor_sweep -> extract _list_labeled_sandboxes /
  _list_live_agent_containers / _prune_grace
- x_client.fetch_mentions -> extract _parse_mention_items
- x_engine.run_cycle -> extract _process_mentions
- orchestrator._dispatch_pm_work -> extract the source-skip into a
  MODULE-level _is_held_ceo_source (module, not method, so the
  wholesale-mocked dispatcher unit tests exercise the real logic)
- auth/seed.ensure_seed_user -> extract _apply_seed_updates (module avg -> A)

Behavior-preserving; full suite green (11902), xenon clean.

* fix(ci): declare pyjwt + fastapi-users-db-sqlalchemy as direct deps (deptry)

The cloud-auth code imports jwt and fastapi_users_db_sqlalchemy directly
but they were only transitive deps (via fastapi-users), which deptry
(quality gate, DEP003) rejects. Declared explicitly; deptry roboco/ clean.
Missed originally because local make quality stopped at earlier gates
before reaching deptry.

* feat(x): gate mention replies behind ROBOCO_X_REPLIES_ENABLED (default off)

Per CEO decision: the X engine should only post about releases by
default. Reading mentions needs a paid X API tier, so the mention-reply
half is now a deliberate opt-in on top of release posting.

New default-off flag x_replies_enabled gates the mentions poll loop
(_x_mentions_poll_loop) and XEngine.run_cycle; release-post drafting
(the release-proposal approve hook) is unaffected and still runs when
x_engine_enabled + credentials are set. Added to FEATURE_FLAGS + the
panel card. Tests: release posting works with replies off; run_cycle +
the poll loop are no-ops with replies off.

* fix: 401 only redirects to /login when cloud auth is on; panel-token strips .env quotes

Two bugs that together dead-ended login in secure mode:
- client.ts redirected to /login on ANY 401, so a mismatched panel
  token (header-trust/secure mode, cloud auth off) bounced the user to a
  login page whose backend route isn't mounted -> 404. Now it probes
  /auth/status (bare fetch, no interceptor re-entry) and only redirects
  when cloud_auth_enabled.
- make panel-token read the .env secret with grep|cut without stripping
  surrounding quotes, so a quoted ROBOCO_AGENT_AUTH_SECRET produced a
  token signed with the quotes included — which never verifies against
  the orchestrator (docker-compose/pydantic unquote the secret). Now
  strips surrounding single/double quotes.

* fix: git-log 500 on '|' in commit message; X queue shows an empty state

- GET /api/git/log 500'd (ValueError: Invalid isoformat) when a commit
  SUBJECT contained a '|' (e.g. the 'curl|sh' lockdown commit): the
  fixed '|' field delimiter let the subject's pipe shift the split so
  author+date collapsed into one field. Switched to \x1f (Unit
  Separator), which can't appear in commit content. Regression test with
  a piped subject.
- The X Post Queue returned null when empty, so there was no visible
  place for the X drafts. It now renders a discoverable empty state
  pointing at Settings -> X credentials.

* docs: bring docs/rag + docs/map current for v0.17.0 (waves 1-3)

Agent-facing RAG corpus and codebase map updated for every feature in
the 0.17.0 span, code-verified:
- wave 3: sandbox DB, DB network isolation, cloud auth, X engine
  (+ x_replies_enabled sub-flag), board roadmap engine — new RAG
  architecture pages + role/tool/config-reference updates; new symbols,
  migrations 057-059, panel surfaces, and the get_agent_context
  dual-path across the map slices.
- waves 1-2: A2A live view + switchboard, prompter memory
  (search_past_tasks), Secretary edit access + PM-lighter scope, the
  PR-gate auto-submit turn cut (ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED).
- correctness fix: api-routes-schemas.md no longer claims the A2A admin
  routes are reachable by any authenticated agent — they carry a
  _require_ceo gate (wave 2c).

docs/internal, _front.md deltas, and the frozen _complete_map.md
snapshot untouched.

* fix(rag): atomic upsert for indexed-doc tracking (kills e2e segfault)

The indexed-document tracking write used check-then-insert in two paths
(IndexedDocumentRepository.upsert_batch and the file-source
_upsert_doc_record). Under concurrent indexing both callers saw no row
and both inserted, so the second violated uq_indexed_doc_source and
poisoned its transaction — surfacing in CI as the intermittent
_checkin_failed SIGSEGV on the failed connection's pool checkin.

Both paths now use INSERT ... ON CONFLICT DO UPDATE against the
constraint: coalesce keeps an existing title/preview when the new value
is empty (matching the old guards) and metadata is jsonb-merged. The
batch dedupes within itself first (ON CONFLICT can't touch a row twice
in one statement). expire_all after the Core upsert keeps same-session
ORM reads consistent with the merged DB row.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 19:24:00 +02:00
committed by GitHub
co-authored by Renn F
parent 12745352aa
commit 3ccc723cd4
179 changed files with 12734 additions and 948 deletions
@@ -0,0 +1,79 @@
"""Project update accepts the sandbox-services opt-in field.
Sandbox DB/Redis participation must be settable through the normal
ProjectUpdate path (what the panel edit-project dialog calls), or the
operator can't opt a project in.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectUpdate
from roboco.services.project import get_project_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_update_sets_sandbox_services(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
svc = get_project_service(db_session)
await svc.update(
cast("UUID", project.id),
ProjectUpdate(sandbox_services=["postgres", "redis"]),
)
reloaded = await svc.get(cast("UUID", project.id))
assert reloaded is not None
assert reloaded.sandbox_services == ["postgres", "redis"]
@pytest.mark.asyncio
async def test_update_clears_sandbox_services(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
svc = get_project_service(db_session)
await svc.update(
cast("UUID", project.id), ProjectUpdate(sandbox_services=["postgres"])
)
await svc.update(cast("UUID", project.id), ProjectUpdate(sandbox_services=[]))
reloaded = await svc.get(cast("UUID", project.id))
assert reloaded is not None
assert reloaded.sandbox_services == []
+8 -2
View File
@@ -242,11 +242,14 @@ async def test_status_git_command_error_directly() -> None:
@pytest.mark.asyncio
async def test_log_with_branch_success(git_client: dict) -> None:
# Fields are \x1f-delimited (not "|"): the second commit's subject contains
# a literal "|" (e.g. "curl|sh") which used to shift the split and land the
# author+date in one field, 500-ing on datetime.fromisoformat.
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = (
"abc123|abc|fix bug|me|2026-01-01T00:00:00Z\n"
"def456|def|other change|you|2026-01-01T00:00:01Z\n"
"abc123\x1fabc\x1ffix bug\x1fme\x1f2026-01-01T00:00:00Z\n"
"def456\x1fdef\x1fclose curl|sh RCE\x1fyou\x1f2026-01-01T00:00:01Z\n"
)
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
@@ -258,6 +261,9 @@ async def test_log_with_branch_success(git_client: dict) -> None:
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
commits = response.json()["commits"]
assert [c["message"] for c in commits] == ["fix bug", "close curl|sh RCE"]
assert [c["author"] for c in commits] == ["me", "you"]
@pytest.mark.asyncio
@@ -52,6 +52,33 @@ async def test_upsert_batch_updates_existing(
assert count == 1
@pytest.mark.asyncio
async def test_upsert_batch_dedupes_within_batch_and_merges(
repo: IndexedDocumentRepository,
) -> None:
# A batch containing the SAME source twice must not raise
# ("ON CONFLICT cannot affect row a second time") — the atomic upsert
# dedupes within the batch (last wins) and merges metadata.
count = await repo.upsert_batch(
"learnings",
[
{"source": "dup", "title": "First", "metadata": {"a": 1}},
{"source": "dup", "title": "Second", "metadata": {"b": 2}},
],
)
assert count == 1
rows = await repo.get_by_index_type("learnings")
assert len(rows) == 1
assert rows[0].title == "Second"
# re-upsert the same source (the race scenario, sequentially): keeps the
# existing title on an empty new one, and merges metadata.
await repo.upsert_batch("learnings", [{"source": "dup", "metadata": {"c": 3}}])
rows = await repo.get_by_index_type("learnings")
assert len(rows) == 1
assert rows[0].title == "Second"
assert rows[0].extra_data == {"b": 2, "c": 3}
@pytest.mark.asyncio
async def test_get_by_index_type(repo: IndexedDocumentRepository) -> None:
await repo.upsert_batch(
@@ -0,0 +1,67 @@
"""Sandboxed per-agent DB/Redis per-project opt-in column (migration 057).
Migration 057 adds ``projects.sandbox_services`` (varchar[] null). The real
upgrade/downgrade chain is verified separately against a throwaway Postgres;
these assertions guard the resulting schema shape and a value round-trip.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="B-Proj",
slug=f"b-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_sandbox_services_column_default_null(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
assert project.sandbox_services is None
@pytest.mark.asyncio
async def test_sandbox_services_column_round_trip(db_session: AsyncSession) -> None:
project = await _seed_project(db_session)
project.sandbox_services = ["postgres", "redis"]
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.sandbox_services == ["postgres", "redis"]
+227
View File
@@ -0,0 +1,227 @@
"""Board roadmap engine route coverage — CEO-only list/approve/reject."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.roadmap import router as roadmap_router
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services.task import ROADMAP_SOURCE
CEO_UUID = _foundation.AGENTS["ceo"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=f"{slug}-{uuid4().hex[:6]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
session.add(agent)
await session.flush()
return agent
async def _seed_ceo(session: AsyncSession) -> None:
"""The CEO row matching ``CEO_UUID`` — approving an item stamps this id as
the materialized task's ``created_by``, which has an FK to ``agents``."""
if await session.get(AgentTable, CEO_UUID) is not None:
return
session.add(
AgentTable(
id=CEO_UUID,
name="ceo",
slug=f"ceo-{uuid4().hex[:6]}",
role=AgentRole.CEO,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
await _seed_ceo(session)
project = ProjectTable(
id=uuid4(),
name="Backend Service",
slug=f"backend-svc-{uuid4().hex[:6]}",
git_url="https://example.com/backend-svc.git",
assigned_cell=Team.BACKEND,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Roadmap exploration cycle",
description="Explore and propose a themed cycle of roadmap items.",
acceptance_criteria=["propose_roadmap() called once"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
created_by=system.id,
assigned_to=po.id,
team=Team.BOARD,
source=ROADMAP_SOURCE,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
markers.set_roadmap_cycle(
task,
{
"goal": "Close onboarding friction",
"items": [
{
"id": "item-0",
"title": "Streamline signup",
"description": "Cut the signup form from 8 fields to 3",
"acceptance_criteria": ["signup takes < 30s", "conversion up"],
"project_slug": project.slug,
"team": "backend",
"priority": 2,
"rationale": "signup drop-off is the top funnel leak",
"status": "proposed",
"reject_reason": None,
"materialized_task_id": None,
}
],
},
)
await session.flush()
return task, project
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(roadmap_router, prefix="/api/roadmap")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
"""CEO-authed client. Approving an item materializes a new task stamping
``agent.agent_id`` as ``created_by`` (an FK to ``agents``), so this must be
a real seeded row ``CEO_UUID`` not an arbitrary ``uuid4()``."""
await _seed_ceo(db_session)
app = _build_app(db_session, AgentRole.CEO, CEO_UUID)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_list_cycles_returns_authored_cycle(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.get("/api/roadmap/cycles")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert len(body) == 1
assert body[0]["task_id"] == str(task.id)
assert body[0]["goal"] == "Close onboarding friction"
assert len(body[0]["items"]) == 1
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(f"/api/roadmap/cycles/{task.id}/items/item-0/approve")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "approved"
assert body["materialized_task_id"] is not None
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
@pytest.mark.asyncio
async def test_approve_unknown_item_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(
f"/api/roadmap/cycles/{task.id}/items/no-such-item/approve"
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_reject_item_records_reason(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(
f"/api/roadmap/cycles/{task.id}/items/item-0/reject",
json={"reason": "not a priority this quarter"},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "rejected"
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
payload = markers.get_roadmap_cycle(refreshed)
assert payload is not None
assert payload["items"][0]["reject_reason"] == "not a priority this quarter"
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed_cycle(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
list_resp = await client.get("/api/roadmap/cycles")
assert list_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@@ -0,0 +1,100 @@
"""XCredentialsService coverage — encrypt/roundtrip, all-or-nothing set/clear.
Drives a real ``db_session`` via the project's Postgres-backed conftest. The
service never returns plaintext to a caller other than ``get_decrypted`` (the
server-side-only reader) the API layer only ever sees ``has_credentials``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import pytest_asyncio
from roboco.db.tables import XCredentialsTable
from roboco.services.x_credentials import (
XCredentialsService,
XCredentialsValidationError,
get_x_credentials_service,
)
from sqlalchemy import select
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_CREDS = {
"api_key": "ak-test",
"api_secret": "as-test",
"access_token": "at-test",
"access_token_secret": "ats-test",
}
@pytest_asyncio.fixture
async def svc(db_session: AsyncSession) -> AsyncIterator[XCredentialsService]:
yield get_x_credentials_service(db_session)
@pytest.mark.asyncio
async def test_unset_has_no_credentials(svc: XCredentialsService) -> None:
assert await svc.has_credentials() is False
assert await svc.get_decrypted() is None
@pytest.mark.asyncio
async def test_set_all_four_encrypts_and_roundtrips(svc: XCredentialsService) -> None:
has_creds = await svc.set_credentials(**_CREDS)
assert has_creds is True
assert await svc.has_credentials() is True
decrypted = await svc.get_decrypted()
assert decrypted is not None
assert decrypted.api_key == _CREDS["api_key"]
assert decrypted.api_secret == _CREDS["api_secret"]
assert decrypted.access_token == _CREDS["access_token"]
assert decrypted.access_token_secret == _CREDS["access_token_secret"]
@pytest.mark.asyncio
async def test_stored_row_never_holds_plaintext(
svc: XCredentialsService, db_session: AsyncSession
) -> None:
await svc.set_credentials(**_CREDS)
result = await db_session.execute(select(XCredentialsTable).limit(1))
row = result.scalar_one_or_none()
assert row is not None
assert row.api_key_encrypted != _CREDS["api_key"]
assert row.api_secret_encrypted != _CREDS["api_secret"]
assert row.access_token_encrypted != _CREDS["access_token"]
assert row.access_token_secret_encrypted != _CREDS["access_token_secret"]
@pytest.mark.asyncio
async def test_clearing_all_four_removes_row(svc: XCredentialsService) -> None:
await svc.set_credentials(**_CREDS)
has_creds = await svc.set_credentials(
api_key="", api_secret="", access_token="", access_token_secret=""
)
assert has_creds is False
assert await svc.has_credentials() is False
assert await svc.get_decrypted() is None
@pytest.mark.asyncio
async def test_partial_set_is_rejected(svc: XCredentialsService) -> None:
with pytest.raises(XCredentialsValidationError):
await svc.set_credentials(
api_key="only-one", api_secret="", access_token="", access_token_secret=""
)
@pytest.mark.asyncio
async def test_rotate_overwrites_previous_values(svc: XCredentialsService) -> None:
await svc.set_credentials(**_CREDS)
rotated = {k: f"{v}-rotated" for k, v in _CREDS.items()}
await svc.set_credentials(**rotated)
decrypted = await svc.get_decrypted()
assert decrypted is not None
assert decrypted.api_key == rotated["api_key"]
+237
View File
@@ -0,0 +1,237 @@
"""X engine route coverage — CEO-only list/approve/reject + credentials."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.x import router as x_router
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation.policy.content import markers
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services.task import X_POST_SOURCE
from roboco.services.x_client import XClient, XMention, XPostResult
from roboco.services.x_post_service import XPostService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=f"{slug}-{uuid4().hex[:6]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
session.add(agent)
await session.flush()
return agent
async def _seed_draft(session: AsyncSession) -> TaskTable:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
secretary = await _seed_agent(session, AgentRole.SECRETARY, "secretary")
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="X post: release v0.17.0",
description="draft body",
acceptance_criteria=["CEO approves or rejects"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=system.id,
assigned_to=secretary.id,
team=Team.MAIN_PM,
source=X_POST_SOURCE,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
markers.set_x_draft_body(task, "draft body")
markers.set_x_release_version(task, "0.17.0")
await session.flush()
return task
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(x_router, prefix="/api/x")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session, AgentRole.CEO, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
class _StubClient(XClient):
@property
def configured(self) -> bool:
return True
async def post_tweet(self, text: str) -> XPostResult:
_ = text
return XPostResult(posted=True, tweet_id="42", detail="posted")
async def fetch_mentions(
self, since_id: str | None, max_results: int
) -> list[XMention]:
_ = (since_id, max_results)
return []
@pytest.mark.asyncio
async def test_list_posts_returns_open_draft(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.get("/api/x/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert len(body) == 1
assert body[0]["task_id"] == str(task.id)
assert body[0]["body"] == "draft body"
assert body[0]["release_version"] == "0.17.0"
@pytest.mark.asyncio
async def test_approve_posts_and_returns_tweet_id(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
with (
patch(
"roboco.services.x_post_service.build_x_client",
return_value=_StubClient(),
),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
resp = await ceo_client.post(f"/api/x/posts/{task.id}/approve", json={})
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "posted"
assert body["tweet_id"] == "42"
await db_session.refresh(task)
assert task.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_approve_edited_body_over_limit_is_400(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.post(
f"/api/x/posts/{task.id}/approve", json={"edited_body": "x" * 281}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_approve_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post(f"/api/x/posts/{uuid4()}/approve", json={})
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_reject_cancels_and_records_reason(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.post(
f"/api/x/posts/{task.id}/reject", json={"reason": "Not our voice"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json()["reject_reason"] == "Not our voice"
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.CANCELLED
@pytest.mark.asyncio
async def test_credentials_default_is_unset(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get("/api/x/credentials")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["has_credentials"] is False
@pytest.mark.asyncio
async def test_set_credentials_reports_status_never_plaintext(
ceo_client: AsyncClient,
) -> None:
resp = await ceo_client.post(
"/api/x/credentials",
json={
"api_key": "secret-key-value",
"api_secret": "secret-apisecret-value",
"access_token": "secret-token-value",
"access_token_secret": "secret-tokensecret-value",
},
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"has_credentials": True}
assert "secret-key-value" not in resp.text
assert "secret-apisecret-value" not in resp.text
assert "secret-token-value" not in resp.text
assert "secret-tokensecret-value" not in resp.text
status_resp = await ceo_client.get("/api/x/credentials")
assert status_resp.json()["has_credentials"] is True
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed_draft(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
list_resp = await client.get("/api/x/posts")
creds_resp = await client.get("/api/x/credentials")
assert list_resp.status_code == HTTPStatus.FORBIDDEN
assert creds_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()