mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F038/F039] orchestrator: sign X-Agent-Token on self-API calls
The prior self-PATCH 401 fix only carried X-Agent-ID/X-Agent-Role. Arming ROBOCO_AGENT_AUTH_REQUIRED=true made the middleware require a signed X-Agent-Token, so every orchestrator self-call (auto-block / auto-resume / auto-recover / SLA annotation) 401'd and silently no-op'd — wedging paused/blocked parents. Add _system_api_headers() that wraps the base headers with a signed token for the system identity (issue_agent_token); switch all six self-call sites. Dev fallback: no secret set => UNSIGNED sentinel + auth not required.
This commit is contained in:
@@ -102,6 +102,30 @@ _SYSTEM_API_HEADERS = {
|
|||||||
"X-Agent-ID": "00000000-0000-0000-0000-000000000000",
|
"X-Agent-ID": "00000000-0000-0000-0000-000000000000",
|
||||||
"X-Agent-Role": "system",
|
"X-Agent-Role": "system",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _system_api_headers() -> dict[str, str]:
|
||||||
|
"""System identity headers for the orchestrator's internal self-API calls.
|
||||||
|
|
||||||
|
Wraps ``_SYSTEM_API_HEADERS`` and adds a signed ``X-Agent-Token`` for the
|
||||||
|
system identity (F038/F039). Without it, arming
|
||||||
|
``ROBOCO_AGENT_AUTH_REQUIRED=true`` 401s every silent recovery op
|
||||||
|
(auto-block / auto-resume / auto-recover / SLA annotation) and wedges
|
||||||
|
paused/blocked parents — the prior self-PATCH 401 fix only carried
|
||||||
|
``X-Agent-ID`` / ``X-Agent-Role``, so it was incomplete under auth-required.
|
||||||
|
When the HMAC secret is unset (dev), ``issue_agent_token`` returns the
|
||||||
|
``UNSIGNED`` sentinel and auth isn't required, so the self-call still
|
||||||
|
succeeds; the header is present either way so a future arm-when-secret-set
|
||||||
|
doesn't silently break.
|
||||||
|
"""
|
||||||
|
from roboco.agents_config import issue_agent_token
|
||||||
|
|
||||||
|
return {
|
||||||
|
**_SYSTEM_API_HEADERS,
|
||||||
|
"X-Agent-Token": issue_agent_token(
|
||||||
|
_SYSTEM_API_HEADERS["X-Agent-ID"], "system", ""
|
||||||
|
),
|
||||||
|
}
|
||||||
# Consecutive failed recovery probes before the CEO is notified once per episode.
|
# Consecutive failed recovery probes before the CEO is notified once per episode.
|
||||||
_CEO_NOTIFY_THRESHOLD = 10
|
_CEO_NOTIFY_THRESHOLD = 10
|
||||||
|
|
||||||
@@ -2744,7 +2768,7 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=5.0, headers=_SYSTEM_API_HEADERS
|
timeout=5.0, headers=_system_api_headers()
|
||||||
) as client:
|
) as client:
|
||||||
task_or_reason = await self._readiness_fetch_task(client, task_id)
|
task_or_reason = await self._readiness_fetch_task(client, task_id)
|
||||||
if isinstance(task_or_reason, str):
|
if isinstance(task_or_reason, str):
|
||||||
@@ -3043,7 +3067,7 @@ class AgentOrchestrator:
|
|||||||
"""Best-effort GET /tasks/{id}; returns task dict or None on failure."""
|
"""Best-effort GET /tasks/{id}; returns task dict or None on failure."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=5.0, headers=_SYSTEM_API_HEADERS
|
timeout=5.0, headers=_system_api_headers()
|
||||||
) as client:
|
) as client:
|
||||||
resp = await client.get(f"{self._api_url}/tasks/{task_id}")
|
resp = await client.get(f"{self._api_url}/tasks/{task_id}")
|
||||||
if resp.status_code == http_status.HTTP_200_OK:
|
if resp.status_code == http_status.HTTP_200_OK:
|
||||||
@@ -4518,7 +4542,7 @@ class AgentOrchestrator:
|
|||||||
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
|
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=3.0, headers=_SYSTEM_API_HEADERS
|
timeout=3.0, headers=_system_api_headers()
|
||||||
) as client:
|
) as client:
|
||||||
resp = await client.get(sdk_url)
|
resp = await client.get(sdk_url)
|
||||||
if resp.status_code == http_status.HTTP_200_OK:
|
if resp.status_code == http_status.HTTP_200_OK:
|
||||||
@@ -4775,7 +4799,7 @@ class AgentOrchestrator:
|
|||||||
_usage_total_cost = 0.0
|
_usage_total_cost = 0.0
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=3.0, headers=_SYSTEM_API_HEADERS
|
timeout=3.0, headers=_system_api_headers()
|
||||||
) as client:
|
) as client:
|
||||||
for agent_id, instance in list(self._instances.items()):
|
for agent_id, instance in list(self._instances.items()):
|
||||||
if instance.state not in (
|
if instance.state not in (
|
||||||
@@ -5439,7 +5463,7 @@ Start by:
|
|||||||
if not self._instances:
|
if not self._instances:
|
||||||
return
|
return
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=3.0, headers=_SYSTEM_API_HEADERS
|
timeout=3.0, headers=_system_api_headers()
|
||||||
) as client:
|
) as client:
|
||||||
for agent_id, instance in list(self._instances.items()):
|
for agent_id, instance in list(self._instances.items()):
|
||||||
if instance.state not in (
|
if instance.state not in (
|
||||||
@@ -8071,7 +8095,7 @@ Start now: evidence(task_id="{task_id}")
|
|||||||
|
|
||||||
dispatchers: list[tuple[str, Any]] = []
|
dispatchers: list[tuple[str, Any]] = []
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=30.0, headers=_SYSTEM_API_HEADERS
|
timeout=30.0, headers=_system_api_headers()
|
||||||
) as client:
|
) as client:
|
||||||
dispatchers = [
|
dispatchers = [
|
||||||
("pm_work", self._dispatch_pm_work(client)),
|
("pm_work", self._dispatch_pm_work(client)),
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ system identity; these tests lock that the identity is both *present* and
|
|||||||
*authorized* for task writes (otherwise the self-call would 403 instead of act).
|
*authorized* for task writes (otherwise the self-call would 403 instead of act).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.agents_config import verify_agent_token
|
||||||
from roboco.foundation import identity as _foundation
|
from roboco.foundation import identity as _foundation
|
||||||
from roboco.models import AgentRole
|
from roboco.models import AgentRole
|
||||||
from roboco.models.permissions import TASK_PERMISSIONS, TaskAction
|
from roboco.models.permissions import TASK_PERMISSIONS, TaskAction
|
||||||
from roboco.runtime.orchestrator import _SYSTEM_API_HEADERS
|
from roboco.runtime.orchestrator import _SYSTEM_API_HEADERS, _system_api_headers
|
||||||
|
|
||||||
|
|
||||||
def test_system_api_headers_match_the_system_identity() -> None:
|
def test_system_api_headers_match_the_system_identity() -> None:
|
||||||
@@ -26,3 +30,28 @@ def test_system_identity_is_authorized_for_task_writes() -> None:
|
|||||||
# auto-recover / auto-resume drive — is gated behind TaskAction.ASSIGN.
|
# auto-recover / auto-resume drive — is gated behind TaskAction.ASSIGN.
|
||||||
# The identity the orchestrator sends must hold it.
|
# The identity the orchestrator sends must hold it.
|
||||||
assert TaskAction.ASSIGN in TASK_PERMISSIONS[AgentRole.SYSTEM]
|
assert TaskAction.ASSIGN in TASK_PERMISSIONS[AgentRole.SYSTEM]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_api_headers_carry_signed_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# F038/F039: the orchestrator's self-API calls must carry a signed
|
||||||
|
# X-Agent-Token for the system identity, or arming
|
||||||
|
# ROBOCO_AGENT_AUTH_REQUIRED=true 401s every silent recovery op
|
||||||
|
# (auto-block / auto-resume / auto-recover / SLA annotation) and wedges
|
||||||
|
# paused/blocked parents — the self-PATCH 401 fix is incomplete without it.
|
||||||
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secrets.token_hex(32))
|
||||||
|
|
||||||
|
headers = _system_api_headers()
|
||||||
|
token = headers["X-Agent-Token"]
|
||||||
|
assert token and token != "UNSIGNED"
|
||||||
|
assert verify_agent_token(token, headers["X-Agent-ID"], "system", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_api_headers_unsigned_when_secret_unset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# Dev fallback: with no secret set, the token is the UNSIGNED sentinel and
|
||||||
|
# auth is not required, so the self-call still succeeds. The header is
|
||||||
|
# present either way so a future arm-when-secret-set doesn't silently break.
|
||||||
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
|
||||||
|
headers = _system_api_headers()
|
||||||
|
assert headers["X-Agent-Token"] == "UNSIGNED"
|
||||||
|
|||||||
Reference in New Issue
Block a user