mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(security): active guard enforcement, CEO A2A target check, notification expiry (#595)
* fix(security): guard goes active; CEO A2A respects no-comms roles; ack notifications expire ROBOCO_GUARD_PASSIVE_MODE defaults to false in both compose files — the deferred post-calibration flip; fail_secure stays off and the env override remains the rollback. can_a2a_direct no longer short-circuits the CEO past the no-comms set (auditor/pr_reviewer/prompter/secretary), now canonical in foundation.policy.communications.NO_COMMS_ROLES and shared with the content-actions gate; the A2A service refuses at conversation creation instead of silently suppressing the wake. Ack-required notifications get expires_at stamped from ROBOCO_NOTIFICATION_ACK_TTL_HOURS (default 48, 0 disables), so the re-escalation sweeper's expires_at query matches rows for the first time. * refactor(notification): extract _ack_and_expiry — xenon rank back under B The expires_at stamping pushed _create_notification_with_session to rank C; the requires_ack + expiry derivation moves into a helper with the same semantics and comments. * test(conftest): dispose the global DB engine after every test Production code reaching get_db_context()/get_engine() lazily creates the process-global engine bound to the current event loop; with per-test function-scoped loops, any later test touching the global path inherits a dead-loop engine and dies with 'Future attached to a different loop' — the order-dependent class that has been wandering the suite (cloud_auth login, metrics, tasks-routes, full-lifecycle) whenever collection order shifts. An autouse fixture now close_db()s after every test, keeping the global path loop-local; no-op when untouched. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
+4
-2
@@ -235,8 +235,10 @@ ROBOCO_DB_NETWORK_ISOLATED=true
|
||||
# fastapi-guard HTTP security layer — v0.16.0
|
||||
# =============================================================================
|
||||
# Master switch + calibration knobs. Off by default; the NAS build compose
|
||||
# arms it in PASSIVE (log-only) mode for false-positive review before
|
||||
# enforcing. Not exposed on the panel's Feature Flags card (still calibrating).
|
||||
# arms it in ACTIVE enforcement (passive/log-only calibration reviewed
|
||||
# clean — see docs/rag/architecture/http-security-guard.md). Not exposed on
|
||||
# the panel's Feature Flags card (compose/env-coupled, like
|
||||
# ROBOCO_DB_NETWORK_ISOLATED).
|
||||
# ROBOCO_GUARD_ENABLED=false
|
||||
# Detect-and-log without blocking.
|
||||
# ROBOCO_GUARD_PASSIVE_MODE=true
|
||||
|
||||
@@ -9,9 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
### Security
|
||||
|
||||
- **Orchestrator API is no longer published on a routable host interface (GHSA-4f7g-w95g-5q2c).** Both deploy composes published the orchestrator's `:8000` on `0.0.0.0`, so anyone who could reach the host hit the control plane directly — bypassing nginx and, in the default header-trust posture (`ROBOCO_AGENT_AUTH_REQUIRED` unset, cloud auth off), reading/writing runtime settings and spoofing `X-Agent-Role: ceo` to spawn/stop agents with no credential. The publish is now bound to `127.0.0.1`; nginx reaches the API over the internal Docker network, so normal operation and on-host debugging are unchanged, while off-host access must go through nginx + cloud auth. The header-trust design itself is unchanged (it stays the deliberate local-no-login panel path); this closes the unintended off-host reachability that gave it teeth.
|
||||
- **fastapi-guard flipped to active enforcement on the NAS build compose.** `ROBOCO_GUARD_PASSIVE_MODE`'s default flips from `true` to `false` in `docker-compose.yml`/`.yaml` (the registry compose already omits the guard trio and stays off) — the deliberately deferred step from the original guard build, now that passive-mode calibration reviewed clean and cloud auth + Tailscale are armed. A matching request is genuinely blocked now, not just logged; see `docs/rag/troubleshooting/blocked-http-requests.md` for the hygiene rules that stop legitimate agent traffic (e.g. quoting `ROBOCO_ENCRYPTION_KEY=<placeholder>`-shaped doc text, or "bypass/disable the guard" phrasing) from tripping it.
|
||||
- **`can_a2a_direct` refuses a CEO DM to a no-comms-role target.** The CEO's asymmetric "may DM any agent" branch returned `True, None` unconditionally, bypassing the non-DM-role exclusion (auditor, pr_reviewer, prompter, secretary — none carry `dm`/`read_a2a` on their manifest) that the panel's New-DM dialog only enforced client-side. A conversation to one of these roles is now refused at `get_or_create_conversation` with a clear reason instead of silently creating an unreadable/unackable thread. The refusal set (`NO_COMMS_ROLES`) is now canonical in `roboco/foundation/policy/communications.py`, reused by both this check and the `dm()` sender-side guard in `content_actions.py` so the two can't drift apart.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Notification `expires_at` is now stamped at creation, so the ack-required re-escalation sweep can actually fire.** `NotificationService._create_notification` built every row without `expires_at`, so `NotificationDeliveryService.sweep_expired_notifications`'s `expires_at < now()` query always matched zero rows — every notification was effectively immortal, and a stuck ack-required notification never got re-escalated to the recipient's up-role. Ack-required rows are now stamped `now() + notification_ack_ttl_hours` (new setting, default 48h, `0` disables stamping); informational (non-ack-required) rows are left `NULL` since the sweep never touches them.
|
||||
- **Python quality gate: restored green on roboco-api@slave (CI run 29653535468).** Two independent code-level bugs broke the 'Python quality gate' job, not a dependency pin (pydantic-settings was already correctly pinned at 2.14.2). ① `roboco/services/git.py`: `_delete_remote_branch_best_effort` was typed to return `bool` but its success path fell off the function end with no return statement, failing mypy's `missing-return-statement` check and silently returning `None` instead of the documented `True` — added the missing `return True`. ② `roboco/services/company_goals.py`: `CompanyGoalsService.upsert`'s six repetitive `if key in data: row.key = data[key]` branches pushed the module's average cyclomatic complexity past xenon's `--max-modules A` gate — refactored into a data-driven loop over a `_MUTABLE_FIELDS` tuple (behaviourally identical, now rank A). Verified by reproducing all three CI steps locally on the slave-branch worktree: `uv sync --extra dev`, `alembic upgrade head` (all 76 migrations apply cleanly), and `make quality` (13,510 tests, 94.56% coverage, all gates green).
|
||||
|
||||
## [0.25.0] - 2026-07-16
|
||||
|
||||
@@ -35,9 +35,11 @@
|
||||
# stays conservative; the build compose arms most of them ON for the
|
||||
# personal NAS deploy. ROBOCO_ROUTING_STRICT and the fastapi-guard trio
|
||||
# (ROBOCO_GUARD_ENABLED/_PASSIVE_MODE/_FAIL_SECURE) are the exception:
|
||||
# both are still mid-calibration on the personal deploy, so they're
|
||||
# omitted entirely here rather than carried — their config defaults
|
||||
# (graceful-degrade routing, guard off) are already the safe posture.
|
||||
# ROUTING_STRICT is still mid-calibration, and guard is now ACTIVE
|
||||
# enforcement on the personal deploy (passive calibration reviewed clean).
|
||||
# Both stay omitted here rather than carried — a third-party deployer
|
||||
# hasn't run that calibration against their own traffic, so their config
|
||||
# defaults (graceful-degrade routing, guard off) are the safer posture.
|
||||
# - Host path defaults differ (/opt/roboco vs /volume1/roboco, ${HOME}
|
||||
# instead of a hardcoded /home/renzof) — registry targets a generic host.
|
||||
# - MinIO (object storage for rendered videos) is intentionally omitted —
|
||||
|
||||
+8
-6
@@ -633,14 +633,16 @@ services:
|
||||
# that would respawn forever. Default-OFF in config; ARMED here (inert in
|
||||
# practice: every real delivery role is gateway-enabled). OFF in registry.
|
||||
ROBOCO_SPAWN_PREFLIGHT_ENABLED: ${ROBOCO_SPAWN_PREFLIGHT_ENABLED:-true}
|
||||
# fastapi-guard HTTP security layer (v0.16.0). ARMED here in PASSIVE /
|
||||
# log-only calibration mode: guard mounts + observes + logs what it WOULD
|
||||
# block, but blocks nothing until PASSIVE_MODE is flipped off after the
|
||||
# false-positive review. FAIL_SECURE=false so a guard-internal error never
|
||||
# 500s this personal deploy. Left OFF entirely in the registry compose.
|
||||
# fastapi-guard HTTP security layer (v0.16.0). ACTIVE enforcement: passive
|
||||
# / log-only calibration (WAF false-positive review, see
|
||||
# docs/rag/architecture/http-security-guard.md) came back clean, CEO
|
||||
# approved flipping to active now that cloud auth + Tailscale are armed.
|
||||
# Guard mounts, observes, AND BLOCKS matching requests. FAIL_SECURE=false
|
||||
# so a guard-internal error never 500s this personal deploy. Left OFF
|
||||
# entirely in the registry compose.
|
||||
# enforce_https follows ROBOCO_ENVIRONMENT (dev on the NAS → not enforced).
|
||||
ROBOCO_GUARD_ENABLED: ${ROBOCO_GUARD_ENABLED:-true}
|
||||
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-true}
|
||||
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-false}
|
||||
ROBOCO_GUARD_FAIL_SECURE: ${ROBOCO_GUARD_FAIL_SECURE:-false}
|
||||
volumes:
|
||||
# Docker socket - allows spawning agent containers
|
||||
|
||||
+8
-6
@@ -633,14 +633,16 @@ services:
|
||||
# that would respawn forever. Default-OFF in config; ARMED here (inert in
|
||||
# practice: every real delivery role is gateway-enabled). OFF in registry.
|
||||
ROBOCO_SPAWN_PREFLIGHT_ENABLED: ${ROBOCO_SPAWN_PREFLIGHT_ENABLED:-true}
|
||||
# fastapi-guard HTTP security layer (v0.16.0). ARMED here in PASSIVE /
|
||||
# log-only calibration mode: guard mounts + observes + logs what it WOULD
|
||||
# block, but blocks nothing until PASSIVE_MODE is flipped off after the
|
||||
# false-positive review. FAIL_SECURE=false so a guard-internal error never
|
||||
# 500s this personal deploy. Left OFF entirely in the registry compose.
|
||||
# fastapi-guard HTTP security layer (v0.16.0). ACTIVE enforcement: passive
|
||||
# / log-only calibration (WAF false-positive review, see
|
||||
# docs/rag/architecture/http-security-guard.md) came back clean, CEO
|
||||
# approved flipping to active now that cloud auth + Tailscale are armed.
|
||||
# Guard mounts, observes, AND BLOCKS matching requests. FAIL_SECURE=false
|
||||
# so a guard-internal error never 500s this personal deploy. Left OFF
|
||||
# entirely in the registry compose.
|
||||
# enforce_https follows ROBOCO_ENVIRONMENT (dev on the NAS → not enforced).
|
||||
ROBOCO_GUARD_ENABLED: ${ROBOCO_GUARD_ENABLED:-true}
|
||||
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-true}
|
||||
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-false}
|
||||
ROBOCO_GUARD_FAIL_SECURE: ${ROBOCO_GUARD_FAIL_SECURE:-false}
|
||||
volumes:
|
||||
# Docker socket - allows spawning agent containers
|
||||
|
||||
@@ -11,7 +11,7 @@ RoboCo's HTTP request layer is protected by `fastapi-guard` (v7.2.1), implemente
|
||||
| `ROBOCO_GUARD_ENABLED` | `false` | Master switch. Off = completely inert — no middleware is mounted, the request path is entirely unchanged, and nothing is logged or blocked. |
|
||||
| `ROBOCO_GUARD_PASSIVE_MODE` | see below | When the guard is enabled, controls whether it blocks matching requests or only logs them. |
|
||||
|
||||
As of 2026-07-01 the guard is built on the `feature/fastapi-guard-hardening` branch, gated off by default, and wherever it is enabled at all it is running in passive/log-only mode — so no agent request is currently being blocked by it anywhere.
|
||||
As of 2026-07-19 the guard is gated off by default in config, but the NAS build compose arms it ON in ACTIVE enforcement (`ROBOCO_GUARD_PASSIVE_MODE=false`) — passive/log-only calibration came back clean, and the CEO approved the flip now that cloud auth + Tailscale are armed. A matching request on that deploy is actually blocked, not just logged. The registry compose still ships it fully off (see Enforcement Posture below).
|
||||
|
||||
## When Armed
|
||||
|
||||
@@ -27,7 +27,7 @@ On top of those generic checks, three RoboCo-specific custom validators run agai
|
||||
|
||||
## Enforcement Posture
|
||||
|
||||
`ROBOCO_GUARD_PASSIVE_MODE` decides what happens on a match: `true` (passive) detects and logs only, and never blocks a request — this is how the NAS production deploy is armed today. `false` (enforce) actually blocks the matching request.
|
||||
`ROBOCO_GUARD_PASSIVE_MODE` decides what happens on a match: `true` (passive) detects and logs only, and never blocks a request. `false` (enforce) actually blocks the matching request — this is how the NAS build compose is armed today (its default flipped from `true` to `false` once passive-mode calibration reviewed clean). The registry compose omits the guard trio entirely, leaving a fresh third-party deploy on the safe config default (guard off).
|
||||
|
||||
A blocked request gets a generic `400` or `403` response — no rule or signature detail is returned, so the response body can't be used to fingerprint what tripped the guard.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ A call to the orchestrator API fails with a generic `400` or `403` and no detail
|
||||
|
||||
## Cause
|
||||
|
||||
RoboCo's HTTP security layer (`fastapi-guard`; see `docs/rag/architecture/http-security-guard.md`) rejected the request. This can only happen when both `ROBOCO_GUARD_ENABLED=true` and `ROBOCO_GUARD_PASSIVE_MODE=false` (enforce mode) are set. As of 2026-07-01 the guard is off by default, and wherever it is enabled it runs passive/log-only, so this is not something that occurs today — it's documented so the block is recognizable if/when enforcement is turned on later.
|
||||
RoboCo's HTTP security layer (`fastapi-guard`; see `docs/rag/architecture/http-security-guard.md`) rejected the request. This can only happen when both `ROBOCO_GUARD_ENABLED=true` and `ROBOCO_GUARD_PASSIVE_MODE=false` (enforce mode) are set. As of 2026-07-19 that is the default on the NAS build compose — a matching request is actually blocked there, not just logged.
|
||||
|
||||
The guard never returns which rule or signature matched, by design, so the 400/403 body itself gives you nothing to act on. Avoiding the triggers below is the only real mitigation.
|
||||
|
||||
@@ -14,18 +14,22 @@ The guard never returns which rule or signature matched, by design, so the 400/4
|
||||
|
||||
Generic WAF signatures (SQL injection, XSS, path traversal, suspicious URL patterns) are excluded from scanning on the free-text body fields of `note` / `commit` / `dm`, so normal code, SQL, diffs, file paths, and URLs in those bodies are safe from that layer. Three custom validators scan those same bodies regardless of that exclusion:
|
||||
|
||||
- Prompt-injection detection
|
||||
- Secret-exfil detection — literal credential-shaped strings (`sk-ant-...`, `ghp_...`, postgres connection URLs) or phrasing like "reveal your api keys"
|
||||
- Prompt-injection detection — phrasing like "ignore previous instructions", or "bypass/disable/override the guard/filter/restriction" (a real risk in this repo's own security-work commit messages and notes: `roboco/security.py`'s own doctring vocabulary uses "guard", "bypass", "disable" constantly)
|
||||
- Secret-exfil detection — literal credential-shaped strings (`sk-ant-...`, `ghp_...`, postgres connection URLs) **or the literal doc pattern `ROBOCO_ENCRYPTION_KEY=<...>` / `ROBOCO_AGENT_AUTH_SECRET=<...>` / `FERNET_KEY=<...>`** (matches `CLAUDE.md` and `.env.example`'s own env-var documentation verbatim — quoting or editing those lines in a `note`/`commit` body trips this) or phrasing like "reveal your api keys"
|
||||
- Internal-SSRF detection — fetch-type bodies targeting internal or metadata hosts (`169.254.169.254`, `roboco-*` internal service hostnames)
|
||||
|
||||
The three custom validators scan the raw request body regardless of which top-level field the text sits in — unlike the WAF's field exclusion, there is no safe field for these three.
|
||||
|
||||
## Solution: Hygiene Rules
|
||||
|
||||
Follow these when composing `note` / `commit` / `dm` bodies or any fetch-type payload, regardless of whether enforcement is currently active:
|
||||
Follow these when composing `note` / `commit` / `dm` bodies or any fetch-type payload:
|
||||
|
||||
1. Never paste real secrets or credentials (API keys, tokens, DB connection strings) into a request body, even inside a code snippet or diff.
|
||||
2. Never aim a fetch/HTTP-call body at an internal service host (`roboco-*`) or a cloud metadata endpoint (`169.254.169.254`).
|
||||
3. Code, SQL, diffs, file paths, and HTML snippets are otherwise fine to include — the WAF layer is calibrated to exclude legitimate agent content on those fields.
|
||||
2. When discussing an env var like `ROBOCO_ENCRYPTION_KEY` in a note/commit body, don't write it as `NAME=value` (even a placeholder value) — write `ROBOCO_ENCRYPTION_KEY` and describe the value separately, e.g. "set to a generated Fernet key", to avoid the `NAME=<10+ chars>` credential-shape match.
|
||||
3. Avoid "bypass/override/disable the guard/filter/restriction/safety" phrasing in commit messages or notes about this security layer itself — describe the change without that verb+noun adjacency (e.g. "excludes X from the WAF scan" instead of "bypasses the guard's WAF scan").
|
||||
4. Never aim a fetch/HTTP-call body at an internal service host (`roboco-*`) or a cloud metadata endpoint (`169.254.169.254`).
|
||||
5. Code, SQL, diffs, file paths, and HTML snippets are otherwise fine to include — the WAF layer is calibrated to exclude legitimate agent content on those fields (this exclusion does not cover the three custom validators above).
|
||||
|
||||
## Current Status (2026-07-01)
|
||||
## Current Status (2026-07-19)
|
||||
|
||||
The guard is built on the `feature/fastapi-guard-hardening` branch, off by default (`ROBOCO_GUARD_ENABLED=false`), and wherever enabled it runs in passive/log-only mode. No agent request is being blocked by it today — the rules above are about good hygiene now and correctness later, not a live restriction.
|
||||
`ROBOCO_GUARD_ENABLED` is off by default in config; the NAS build compose arms it ON with `ROBOCO_GUARD_PASSIVE_MODE=false` (enforce). A matching request on that deploy is genuinely blocked, not just logged — the rules above are a live restriction there, not just future-proofing. The registry compose and a bare config default both stay off.
|
||||
|
||||
+20
-2
@@ -632,6 +632,24 @@ def _check_main_pm_a2a(to_role: str, to_team: str | None) -> tuple[bool, str | N
|
||||
return False, f"Main PM cannot A2A {to_role}s. Route through {pm or 'cell-pm'}."
|
||||
|
||||
|
||||
def _check_ceo_a2a(to_role: str) -> tuple[bool, str | None]:
|
||||
"""Check A2A permissions for the CEO's asymmetric send-to-anyone reach.
|
||||
|
||||
A target with no agent-comms surface (no dm/read_a2a on its manifest —
|
||||
auditor, pr_reviewer, prompter, secretary) can never read or answer a
|
||||
DM regardless of who sends it; the panel's New-DM dialog already
|
||||
excludes these roles client-side (EXCLUDE_NON_DM_ROLES), this is the
|
||||
server-side backstop so a direct API/A2A-service call can't bypass it.
|
||||
"""
|
||||
if to_role in _comms.NO_COMMS_ROLES:
|
||||
return (
|
||||
False,
|
||||
f"'{to_role}' has no agent-comms surface (no dm/read_a2a) and "
|
||||
"cannot receive a DM.",
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def _check_pr_reviewer_a2a(to_role: str) -> tuple[bool, str | None]:
|
||||
"""Check A2A permissions for a PR reviewer.
|
||||
|
||||
@@ -659,9 +677,9 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
|
||||
|
||||
# The CEO (human, via the panel) may chime into any agent's A2A thread —
|
||||
# the one asymmetric rule in this matrix: CEO may send, nobody may
|
||||
# target CEO.
|
||||
# target CEO (except a no-comms target, see _check_ceo_a2a).
|
||||
if from_role == "ceo":
|
||||
return True, None
|
||||
return _check_ceo_a2a(to_role)
|
||||
|
||||
# CEO is human - agents can never INITIATE with the CEO. The only path in
|
||||
# is a reply inside a conversation the CEO itself opened (enforced
|
||||
|
||||
@@ -267,6 +267,18 @@ class Settings(BaseSettings):
|
||||
"unacknowledged. 0 disables the damper (legacy every-tick respawn)."
|
||||
),
|
||||
)
|
||||
notification_ack_ttl_hours: int = Field(
|
||||
default=48,
|
||||
ge=0,
|
||||
description=(
|
||||
"Hours until an ack-required notification's expires_at is stamped "
|
||||
"at creation. sweep_expired_notifications re-escalates a still-"
|
||||
"unacked row past that deadline to the recipient's up-role. "
|
||||
"Informational (non-ack-required) notifications never get a "
|
||||
"deadline regardless of this setting. 0 disables stamping "
|
||||
"(legacy: expires_at stays NULL, notifications never expire)."
|
||||
),
|
||||
)
|
||||
audit_interval_seconds: int = Field(
|
||||
default=21600,
|
||||
ge=0,
|
||||
|
||||
@@ -56,6 +56,23 @@ NOTIFY_SENDER_ROLES: frozenset[Role] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# Roles with no agent-comms surface at all: auditor (silent observer, no dm/
|
||||
# read_a2a on its manifest), pr_reviewer (posts findings on the PR itself),
|
||||
# and the human-only prompter/secretary (note + evidence only). A DM to any
|
||||
# of these is a black hole — nothing on the other end can read or answer it.
|
||||
# Canonical set consumed by both the dm() sender-side guard
|
||||
# (services.gateway.content_actions) and the CEO's asymmetric target check
|
||||
# (agents_config.can_a2a_direct) so the two never drift apart.
|
||||
NO_COMMS_ROLES: frozenset[Role] = frozenset(
|
||||
{
|
||||
Role.AUDITOR,
|
||||
Role.PR_REVIEWER,
|
||||
Role.PROMPTER,
|
||||
Role.SECRETARY,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# NotificationType -> requires_ack mapping.
|
||||
# Convention from spec §5.5:
|
||||
# - Action-required (CEO/PM acks needed) -> True
|
||||
|
||||
@@ -107,10 +107,10 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
|
||||
# that refuses any call that bypassed the manifest (direct verb dispatch, test
|
||||
# harness, future routing change), so the no-comms invariant holds regardless of
|
||||
# how the call arrived. Matches the explicit role-frozenset gates on commit /
|
||||
# notify / pitch / playbook.
|
||||
_NO_COMMS_ROLES: frozenset[str] = frozenset(
|
||||
{"auditor", "pr_reviewer", "prompter", "secretary"}
|
||||
)
|
||||
# notify / pitch / playbook. Derived from the canonical set in
|
||||
# foundation.policy.communications — agents_config.can_a2a_direct's CEO
|
||||
# target-side check reuses the same source.
|
||||
_NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES)
|
||||
|
||||
|
||||
def _no_comms_remediate(role: str) -> str:
|
||||
|
||||
@@ -6,12 +6,14 @@ Sends notifications through the API with proper enforcement.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.base import get_db_context
|
||||
from roboco.db.tables import AgentTable, NotificationTable
|
||||
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
|
||||
@@ -847,6 +849,32 @@ class NotificationService:
|
||||
if created:
|
||||
await db.commit()
|
||||
|
||||
def _ack_and_expiry(
|
||||
self, params: CreateNotificationParams
|
||||
) -> tuple[bool, datetime | None]:
|
||||
"""(requires_ack, expires_at) for a new notification row.
|
||||
|
||||
requires_ack follows ACK_REQUIRED_BY_TYPE (action-required vs
|
||||
informational) with default True for unmapped types; a per-row
|
||||
override (params.requires_ack) wins when set — used by
|
||||
send_a2a_notification's CEO-wake path. expires_at feeds
|
||||
sweep_expired_notifications' re-escalation: only ack-required rows
|
||||
are ever swept, so only they get a deadline, and
|
||||
notification_ack_ttl_hours=0 disables stamping (NULL, never
|
||||
expires).
|
||||
"""
|
||||
requires_ack = (
|
||||
params.requires_ack
|
||||
if params.requires_ack is not None
|
||||
else ACK_REQUIRED_BY_TYPE.get(params.notification_type, True)
|
||||
)
|
||||
expires_at = (
|
||||
datetime.now(UTC) + timedelta(hours=settings.notification_ack_ttl_hours)
|
||||
if requires_ack and settings.notification_ack_ttl_hours > 0
|
||||
else None
|
||||
)
|
||||
return requires_ack, expires_at
|
||||
|
||||
async def _create_notification_with_session(
|
||||
self, params: CreateNotificationParams, db: AsyncSession
|
||||
) -> bool:
|
||||
@@ -903,6 +931,7 @@ class NotificationService:
|
||||
to_agents_uuids=to_agents_uuids,
|
||||
):
|
||||
return False
|
||||
requires_ack, expires_at = self._ack_and_expiry(params)
|
||||
notification = NotificationTable(
|
||||
type=params.notification_type,
|
||||
priority=params.priority,
|
||||
@@ -911,18 +940,8 @@ class NotificationService:
|
||||
subject=params.subject,
|
||||
body=params.body,
|
||||
related_task_id=params.related_task_id,
|
||||
# requires_ack follows ACK_REQUIRED_BY_TYPE (action-required vs
|
||||
# informational), not the column's True default; default True
|
||||
# for an unmapped type preserves the safe action-required bias.
|
||||
# A per-row override (params.requires_ack) wins when set — used
|
||||
# by send_a2a_notification's CEO-wake path so that row is
|
||||
# visible under pending_ack_only even though A2A_REQUEST's type
|
||||
# default is False.
|
||||
requires_ack=(
|
||||
params.requires_ack
|
||||
if params.requires_ack is not None
|
||||
else ACK_REQUIRED_BY_TYPE.get(params.notification_type, True)
|
||||
),
|
||||
requires_ack=requires_ack,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.flush()
|
||||
|
||||
+19
-1
@@ -36,6 +36,7 @@ Redis isolation:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
@@ -48,7 +49,7 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.config import settings as _settings
|
||||
from roboco.db import tables as roboco_tables
|
||||
from roboco.db.base import Base
|
||||
from roboco.db.base import Base, close_db
|
||||
from roboco.db.tables import (
|
||||
AgentTable,
|
||||
AuditLogTable,
|
||||
@@ -78,6 +79,23 @@ if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _dispose_global_db_engine() -> AsyncIterator[None]:
|
||||
"""Never let the lazy global engine outlive the test that created it.
|
||||
|
||||
Production code reaching ``get_db_context()``/``get_engine()`` creates
|
||||
the process-global ``_DbHolder`` engine bound to the CURRENT event loop.
|
||||
With function-scoped test loops, any later test touching that global
|
||||
path inherits an engine from a dead loop and crashes with ``Future
|
||||
attached to a different loop`` — an order-dependent failure class that
|
||||
moves around whenever test collection shifts. Disposing after every
|
||||
test keeps the global path loop-local; a no-op when nothing touched it.
|
||||
"""
|
||||
yield
|
||||
with contextlib.suppress(Exception):
|
||||
await close_db()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_live_redis(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep every test off the real localhost Redis (see module docstring).
|
||||
|
||||
@@ -2494,21 +2494,36 @@ async def test_agent_reply_to_ceo_creates_no_wake(a2a_setup: dict) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_dm_to_non_a2a_role_creates_no_wake(a2a_setup: dict) -> None:
|
||||
"""A CEO DM to a role with no read_a2a on its manifest (pr_reviewer,
|
||||
auditor) must NOT create a wake row — the recipient could never ack it,
|
||||
so it would be immortal, permanently suppress future wakes via the dedup
|
||||
pre-check, and drive futile respawns."""
|
||||
async def test_ceo_dm_to_non_a2a_role_denied_at_conversation_creation(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
"""A CEO DM to a role with no dm/read_a2a on its manifest (pr_reviewer,
|
||||
auditor) must be refused outright at conversation creation — the root-
|
||||
cause fix (can_a2a_direct's CEO branch now excludes NO_COMMS_ROLES)
|
||||
supersedes the old symptom-level fix of letting the conversation exist
|
||||
and only suppressing the wake notification (the recipient could never
|
||||
ack it, so it would be immortal, permanently suppress future wakes via
|
||||
the dedup pre-check, and drive futile respawns)."""
|
||||
svc: A2AService = a2a_setup["svc"]
|
||||
conv = await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1")
|
||||
conv_id = UUID(conv.id)
|
||||
with pytest.raises(A2AAccessDeniedError, match="no agent-comms surface"):
|
||||
await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_wake_ceo_recipient_still_noops_for_no_comms_role(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
"""Defense-in-depth: _maybe_wake_ceo_recipient's own read_a2a manifest
|
||||
check independently no-ops for a no-comms role — unreachable through the
|
||||
normal send path now that conversation creation refuses it first, but
|
||||
it must stay safe if ever called directly (e.g. on a pre-fix row)."""
|
||||
svc: A2AService = a2a_setup["svc"]
|
||||
mock_ns = AsyncMock()
|
||||
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
|
||||
with patch(
|
||||
"roboco.services.notification.NotificationService", return_value=mock_ns
|
||||
):
|
||||
await svc.send_chat_message(conv_id, "ceo", "review status?")
|
||||
await svc._maybe_wake_ceo_recipient("ceo", "pr-reviewer-1", None)
|
||||
|
||||
mock_ns.send_a2a_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""expires_at is now stamped at creation (NotificationService) and actually
|
||||
matched by NotificationDeliveryService.sweep_expired_notifications' SQL
|
||||
WHERE clause — before the fix the column was never written, so this query
|
||||
always matched zero rows regardless of how stale a notification was.
|
||||
|
||||
Integration tests against the migrated Postgres DB: `sweep_expired_notifications`
|
||||
issues a real `expires_at < now()` query, so a mocked session (as
|
||||
`tests/unit/services/test_notification_delivery.py` uses) can't exercise it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, NotificationTable
|
||||
from roboco.models import AgentRole, AgentStatus, NotificationPriority, NotificationType
|
||||
from roboco.models.base import Team
|
||||
from roboco.models.notification import CreateNotificationParams
|
||||
from roboco.services.notification import NotificationService
|
||||
from roboco.services.notification_delivery import get_notification_delivery_service
|
||||
from sqlalchemy import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _seed_agent(db: AsyncSession, *, role: AgentRole, slug: str) -> UUID:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name=slug,
|
||||
slug=slug,
|
||||
role=role,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt=slug,
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
return cast("UUID", agent.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_created_notification_expires_at_is_stamped_and_matched_by_sweep(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""End-to-end: NotificationService._create_notification stamps expires_at
|
||||
for an ack-required row, and once that deadline is in the past,
|
||||
sweep_expired_notifications' real Postgres query finds it (count 1) —
|
||||
the exact round trip that was a dead no-op before this fix, since
|
||||
expires_at was always NULL and `expires_at < now()` never matched."""
|
||||
unique = uuid4().hex[:8]
|
||||
sender = await _seed_agent(
|
||||
db_session, role=AgentRole.DEVELOPER, slug=f"sndr-{unique}"
|
||||
)
|
||||
recipient = await _seed_agent(
|
||||
db_session, role=AgentRole.CELL_PM, slug=f"pm-{unique}"
|
||||
)
|
||||
|
||||
svc = NotificationService()
|
||||
await svc._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.BLOCKER_ESCALATION,
|
||||
priority=NotificationPriority.HIGH,
|
||||
from_agent=str(sender),
|
||||
to_agents=[str(recipient)],
|
||||
subject="blocked",
|
||||
body="external dependency",
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
select(NotificationTable).where(
|
||||
NotificationTable.type == NotificationType.BLOCKER_ESCALATION,
|
||||
NotificationTable.from_agent == sender,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.expires_at is not None
|
||||
assert row.requires_ack is True
|
||||
|
||||
# Backdate it past the deadline (no real clock wait) and confirm the
|
||||
# sweep's `expires_at < now()` predicate now actually matches.
|
||||
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await db_session.flush()
|
||||
|
||||
deliv = get_notification_delivery_service(db_session)
|
||||
count = await deliv.sweep_expired_notifications()
|
||||
assert count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directly_stamped_expired_row_is_matched_by_sweep_query(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Isolates the sweep query mechanics from creation: a hand-built
|
||||
ack-required, unacked row with expires_at in the past must be counted."""
|
||||
unique = uuid4().hex[:8]
|
||||
sender = await _seed_agent(
|
||||
db_session, role=AgentRole.DEVELOPER, slug=f"s2-{unique}"
|
||||
)
|
||||
recipient = await _seed_agent(db_session, role=AgentRole.QA, slug=f"r2-{unique}")
|
||||
|
||||
notification = NotificationTable(
|
||||
type=NotificationType.ALERT,
|
||||
priority=NotificationPriority.HIGH,
|
||||
from_agent=sender,
|
||||
to_agents=[recipient],
|
||||
subject="stale alert",
|
||||
body="body",
|
||||
requires_ack=True,
|
||||
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
db_session.add(notification)
|
||||
await db_session.flush()
|
||||
|
||||
deliv = get_notification_delivery_service(db_session)
|
||||
count = await deliv.sweep_expired_notifications()
|
||||
assert count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_ttl_disables_expires_at_stamping_end_to_end(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""notification_ack_ttl_hours=0 leaves expires_at NULL even for an
|
||||
ack-required notification created through the real service."""
|
||||
monkeypatch.setattr(settings, "notification_ack_ttl_hours", 0)
|
||||
unique = uuid4().hex[:8]
|
||||
sender = await _seed_agent(
|
||||
db_session, role=AgentRole.DEVELOPER, slug=f"s3-{unique}"
|
||||
)
|
||||
recipient = await _seed_agent(
|
||||
db_session, role=AgentRole.CELL_PM, slug=f"pm3-{unique}"
|
||||
)
|
||||
|
||||
svc = NotificationService()
|
||||
await svc._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.BLOCKER_ESCALATION,
|
||||
priority=NotificationPriority.HIGH,
|
||||
from_agent=str(sender),
|
||||
to_agents=[str(recipient)],
|
||||
subject="blocked",
|
||||
body="external dependency",
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
select(NotificationTable).where(
|
||||
NotificationTable.type == NotificationType.BLOCKER_ESCALATION,
|
||||
NotificationTable.from_agent == sender,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.expires_at is None
|
||||
@@ -9,6 +9,7 @@ from roboco.enforcement.a2a_access import (
|
||||
get_a2a_allowed_targets,
|
||||
validate_a2a_access,
|
||||
)
|
||||
from roboco.foundation.policy.communications import NO_COMMS_ROLES
|
||||
|
||||
|
||||
def test_validate_a2a_self_a2a_denied() -> None:
|
||||
@@ -94,3 +95,27 @@ def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
|
||||
assert allowed is False
|
||||
assert reason is not None
|
||||
assert "reply" in reason.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target_slug",
|
||||
["auditor", "pr-reviewer-1", "intake-1", "secretary-1"],
|
||||
)
|
||||
def test_can_a2a_direct_ceo_to_no_comms_role_denied(target_slug: str) -> None:
|
||||
"""The CEO's asymmetric reach still can't target a role with no dm/
|
||||
read_a2a on its manifest (auditor, pr_reviewer, prompter, secretary) —
|
||||
nothing on the other end could ever read or answer the DM. The panel's
|
||||
New-DM dialog already filters these client-side (EXCLUDE_NON_DM_ROLES);
|
||||
this is the server-side backstop for a direct API/A2A-service call."""
|
||||
allowed, reason = can_a2a_direct("ceo", target_slug)
|
||||
assert allowed is False
|
||||
assert reason is not None
|
||||
assert "comms" in reason.lower()
|
||||
|
||||
|
||||
def test_can_a2a_direct_ceo_to_no_comms_role_reuses_canonical_set() -> None:
|
||||
"""The refusal set must be exactly foundation.policy.communications'
|
||||
NO_COMMS_ROLES — the same set services.gateway.content_actions uses to
|
||||
gate the dm() sender side — so the two never drift apart."""
|
||||
expected = {"auditor", "pr_reviewer", "prompter", "secretary"}
|
||||
assert {role.value for role in NO_COMMS_ROLES} == expected
|
||||
|
||||
@@ -9,6 +9,7 @@ without spinning up a Postgres + Redis stack.
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -17,6 +18,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
|
||||
from roboco.models import NotificationPriority, NotificationType
|
||||
from roboco.models.notification import CreateNotificationParams
|
||||
@@ -414,6 +416,74 @@ async def test_create_notification_requires_ack_derives_from_type(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# expires_at stamping (notification_ack_ttl_hours) — feeds
|
||||
# NotificationDeliveryService.sweep_expired_notifications' re-escalation.
|
||||
# Column existed but was never written, so the sweep query always matched
|
||||
# zero rows.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_required_notification_gets_expires_at(
|
||||
svc: NotificationService,
|
||||
) -> None:
|
||||
"""An ack-required row (BLOCKER_ESCALATION) is stamped expires_at ~=
|
||||
now + notification_ack_ttl_hours."""
|
||||
aid = uuid4()
|
||||
db = _FakeDb(agent_uuid=aid)
|
||||
before = datetime.now(UTC)
|
||||
with _patch_db_context(db):
|
||||
await svc.send_blocker_notification(
|
||||
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
|
||||
)
|
||||
after = datetime.now(UTC)
|
||||
rows = [r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION]
|
||||
assert rows
|
||||
expires_at = rows[0].expires_at
|
||||
assert expires_at is not None
|
||||
ttl = timedelta(hours=settings.notification_ack_ttl_hours)
|
||||
assert before + ttl <= expires_at <= after + ttl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_informational_notification_gets_no_expires_at(
|
||||
svc: NotificationService,
|
||||
) -> None:
|
||||
"""A non-ack-required row (REVIEW_REQUEST) never gets a deadline — the
|
||||
sweep only ever re-escalates ack-required rows, so stamping one would be
|
||||
dead weight."""
|
||||
aid = uuid4()
|
||||
db = _FakeDb(agent_uuid=aid)
|
||||
with _patch_db_context(db):
|
||||
await svc.send_qa_ready_notification(
|
||||
task_id="t1", from_agent="be-dev-1", to_qa="be-qa"
|
||||
)
|
||||
rows = [r for r in db.added if r.type == NotificationType.REVIEW_REQUEST]
|
||||
assert rows
|
||||
assert rows[0].expires_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_required_notification_expires_at_disabled_by_zero_ttl(
|
||||
svc: NotificationService,
|
||||
) -> None:
|
||||
"""notification_ack_ttl_hours=0 disables stamping entirely (legacy: NULL,
|
||||
never expires) even for an ack-required type."""
|
||||
aid = uuid4()
|
||||
db = _FakeDb(agent_uuid=aid)
|
||||
with (
|
||||
patch("roboco.services.notification.settings.notification_ack_ttl_hours", 0),
|
||||
_patch_db_context(db),
|
||||
):
|
||||
await svc.send_blocker_notification(
|
||||
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
|
||||
)
|
||||
rows = [r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION]
|
||||
assert rows
|
||||
assert rows[0].expires_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coordination-event producers (reassignment / collision / unblock /
|
||||
# dependency-revival / stale-claim-reaped)
|
||||
|
||||
@@ -243,7 +243,6 @@ def test_get_agent_skills_unknown_agent() -> None:
|
||||
def test_issue_agent_token_returns_unsigned_when_secret_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
|
||||
assert issue_agent_token("be-dev-1", "developer", "backend") == "UNSIGNED"
|
||||
|
||||
@@ -262,7 +261,6 @@ def test_issue_agent_token_signs_when_secret_present(
|
||||
|
||||
|
||||
def test_verify_agent_token_round_trips(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "rt-secret")
|
||||
tok = issue_agent_token("be-dev-1", "developer", "backend")
|
||||
assert verify_agent_token(tok, "be-dev-1", "developer", "backend") is True
|
||||
@@ -271,7 +269,6 @@ def test_verify_agent_token_round_trips(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
def test_verify_agent_token_rejects_when_secret_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
|
||||
assert verify_agent_token("anything", "be-dev-1", "developer", "backend") is False
|
||||
|
||||
@@ -279,7 +276,6 @@ def test_verify_agent_token_rejects_when_secret_missing(
|
||||
def test_verify_agent_token_rejects_unsigned_sentinel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "any-secret")
|
||||
assert verify_agent_token("UNSIGNED", "be-dev-1", "developer", "backend") is False
|
||||
|
||||
@@ -287,7 +283,6 @@ def test_verify_agent_token_rejects_unsigned_sentinel(
|
||||
def test_verify_agent_token_rejects_empty_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "any-secret")
|
||||
assert verify_agent_token("", "be-dev-1", "developer", "backend") is False
|
||||
|
||||
@@ -295,7 +290,6 @@ def test_verify_agent_token_rejects_empty_token(
|
||||
def test_verify_agent_token_rejects_mismatched_signature(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "real-secret")
|
||||
tok = issue_agent_token("be-dev-1", "developer", "backend")
|
||||
# Verify with different role → mismatch.
|
||||
@@ -539,10 +533,10 @@ def test_get_a2a_route_hint_unknown_from_agent_falls_through() -> None:
|
||||
# A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXPECTED_PAIR_COUNT = 93
|
||||
_EXPECTED_PAIR_COUNT = 88
|
||||
_EXPECTED_GROUP_COUNTS = {
|
||||
"board": 3,
|
||||
"ceo": 23,
|
||||
"ceo": 18,
|
||||
"cell-backend": 15,
|
||||
"cell-frontend": 15,
|
||||
"cell-ux_ui": 15,
|
||||
@@ -579,15 +573,23 @@ def test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo() -> None:
|
||||
assert "ceo" in slugs
|
||||
|
||||
|
||||
def test_a2a_allowed_pairs_ceo_paired_with_every_agent() -> None:
|
||||
"""CEO → anyone is always allowed, so every non-CEO switchboard slug
|
||||
def test_a2a_allowed_pairs_ceo_paired_with_every_dm_capable_agent() -> None:
|
||||
"""CEO → anyone with an agent-comms surface is allowed, so every non-CEO
|
||||
switchboard slug EXCEPT the no-comms roles (auditor, pr_reviewer — no
|
||||
dm/read_a2a on their manifest, so a CEO DM to them is a black hole)
|
||||
appears in exactly one ``ceo``-group pair."""
|
||||
ceo_pairs = [p for p in A2A_ALLOWED_PAIRS if "ceo" in (p.agent_a, p.agent_b)]
|
||||
non_ceo_slugs = (
|
||||
{p.agent_a for p in A2A_ALLOWED_PAIRS} | {p.agent_b for p in A2A_ALLOWED_PAIRS}
|
||||
) - {"ceo"}
|
||||
dm_capable_slugs = {
|
||||
s for s in non_ceo_slugs if get_agent_role(s) not in ("auditor", "pr_reviewer")
|
||||
}
|
||||
assert all(p.group_key == "ceo" for p in ceo_pairs)
|
||||
assert len(ceo_pairs) == len(non_ceo_slugs)
|
||||
assert len(ceo_pairs) == len(dm_capable_slugs)
|
||||
# And the no-comms roles are confirmed absent from any ceo-group pair.
|
||||
ceo_slugs = {p.agent_a for p in ceo_pairs} | {p.agent_b for p in ceo_pairs}
|
||||
assert ceo_slugs.isdisjoint(non_ceo_slugs - dm_capable_slugs)
|
||||
|
||||
|
||||
def test_a2a_allowed_pairs_group_key_counts() -> None:
|
||||
|
||||
Reference in New Issue
Block a user