Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,8 @@ async def status(self, agent_config: dict, run_id: str) -> AdapterRunStatus:
"""Check status and parse token usage from output."""
base_status = await super().status(agent_config, run_id)

# If run completed, check for quota exhaustion in output
if base_status.status in (LLCRunStatus.COMPLETED, LLCRunStatus.FAILED):
# On any terminal state, check the output for quota exhaustion (GH#9777).
if base_status.status.is_terminal():
cfg = agent_config.get("adapter_config", {})
output_dir: str = cfg.get("output_dir", "/tmp") # nosec B108
agent_id: str = agent_config.get("agent_id", "unknown")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ async def status(self, agent_config: dict, run_id: str) -> AdapterRunStatus:
"""Check status and parse token usage from output."""
base_status = await super().status(agent_config, run_id)

# If run completed, check for quota exhaustion in output
if base_status.status in (LLCRunStatus.COMPLETED, LLCRunStatus.FAILED):
# On any terminal state, check the output for quota exhaustion (GH#9777).
if base_status.status.is_terminal():
cfg = agent_config.get("adapter_config", {})
output_dir: str = cfg.get("output_dir", "/tmp") # nosec B108
agent_id: str = agent_config.get("agent_id", "unknown")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,15 @@ async def test_invoke_writes_state_file(self) -> None:

with tempfile.TemporaryDirectory() as td:
cfg = _agent_cfg(output_dir=td)
# GH#9763: do NOT patch builtins.open here — the state file must be
# written for real to the temp dir so the os.path.exists assertion
# below is meaningful. Only the subprocess + redis are mocked.
with (
patch("llc.adapters.claude_code_adapter.shutil.which", return_value="/usr/bin/claude"),
patch(
"llc.adapters.claude_code_adapter.get_async_redis_client", new_callable=AsyncMock, return_value=None
),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=fake_proc),
patch("builtins.open", MagicMock(return_value=MagicMock())),
):
run_id = await adapter.invoke(cfg, {"task_id": "t99"})

Expand Down
19 changes: 0 additions & 19 deletions autobot-backend/llc/config.py

This file was deleted.

21 changes: 15 additions & 6 deletions autobot-backend/llc/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
# AutoBot - AI-Powered Automation Platform
# Copyright (c) 2025 mrveiss
# Author: mrveiss
"""LLC configuration package (GH#8487).
"""LLC configuration (GH#8487, GH#9776).

Re-exports legacy llc/config.py symbols so existing callers continue to work
now that llc/config/ directory (GH#8487) shadows the old flat module.
Single source of truth for LLC config constants. The legacy flat module
``llc/config.py`` was shadowed by this package and therefore dead — its unique
``DEFAULT_STREAMING_WATCHDOG_TIMEOUT`` was unreachable. It is folded in here and
the flat module deleted (GH#9776).
"""

import os

# Agent API base URL — re-exported from the legacy flat module so that
# ``from llc.config import AGENT_API_BASE_URL`` keeps working (GH#8487).
# Agent API base URL (used for context assembly and heartbeat payloads).
AGENT_API_BASE_URL = os.environ.get("LLC_AGENT_API_BASE_URL", "http://localhost:8001/api")

# Default streaming watchdog timeout (seconds of silence before kill).
# Per-agent override via adapter_config["streaming_watchdog_timeout_seconds"].
DEFAULT_STREAMING_WATCHDOG_TIMEOUT = int(os.environ.get("LLC_STREAMING_WATCHDOG_TIMEOUT", "120"))

# Placeholder written into heartbeat context by HeartbeatContextBuilder before a
# real run-scoped key is issued at dispatch time. Subprocess adapters skip this
# value when injecting AUTOBOT_LLC_API_KEY and redact it from LLC_INVOKE_CONTEXT
# (GH#9623, GH#9789). Shared here so the producer (kb) and consumers (adapters)
# can never drift (GH#9777).
AGENT_API_KEY_PLACEHOLDER = "<injected-at-runtime>"

__all__ = ["AGENT_API_BASE_URL", "AGENT_API_KEY_PLACEHOLDER"]
__all__ = [
"AGENT_API_BASE_URL",
"AGENT_API_KEY_PLACEHOLDER",
"DEFAULT_STREAMING_WATCHDOG_TIMEOUT",
]
18 changes: 5 additions & 13 deletions autobot-backend/llc/middleware/agent_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
Injects request.state.agent_id and request.state.company_id on success.
"""

import hashlib
import logging
from datetime import datetime, timezone

from fastapi import Request
from sqlalchemy import or_, select, update
from sqlalchemy import update
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

from llc.models.api_key import LLCApiKey
from llc.services.api_key import ApiKeyService
from user_management.database import get_async_session_factory

logger = logging.getLogger(__name__)
Expand All @@ -37,20 +37,12 @@ async def dispatch(self, request: Request, call_next):
return JSONResponse({"detail": "Missing bearer token"}, status_code=401)

raw_key = auth[len("Bearer ") :]
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
now = datetime.now(timezone.utc)

# GH#9777: reuse ApiKeyService.validate_key (hash + revoked + expiry checks)
# rather than re-implementing the query/hash here, so the two can't drift.
factory = get_async_session_factory()
async with factory() as session:
result = await session.execute(
select(LLCApiKey).where(
LLCApiKey.key_hash == key_hash,
LLCApiKey.revoked_at.is_(None),
# GH#9623: reject expired ephemeral keys (TTL backstop).
or_(LLCApiKey.expires_at.is_(None), LLCApiKey.expires_at > now),
)
)
key_record = result.scalar_one_or_none()
key_record = await ApiKeyService().validate_key(session, raw_key)

if key_record is None:
return JSONResponse({"detail": "Invalid or revoked token"}, status_code=401)
Expand Down
13 changes: 13 additions & 0 deletions autobot-backend/llc/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ class LLCRunStatus(str, Enum):
CANCELLED = "cancelled"
RATE_LIMITED = "rate_limited"

def is_terminal(self) -> bool:
"""True once the run has reached a final state (not queued/running).

Single source of truth for the terminal/non-terminal split (GH#9777),
replacing ad-hoc ``status in (COMPLETED, FAILED)`` / ``{QUEUED, RUNNING}``
checks scattered across the scheduler and adapters.
"""
return self not in _NONTERMINAL_RUN_STATUSES


# Run states that are still in flight; everything else is terminal (GH#9777).
_NONTERMINAL_RUN_STATUSES = frozenset({LLCRunStatus.QUEUED, LLCRunStatus.RUNNING})


class HeartbeatInvocationSource(str, Enum):
"""How a heartbeat run was triggered (GH#8225, GH#9624)."""
Expand Down
4 changes: 1 addition & 3 deletions autobot-backend/llc/scheduler/heartbeat_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ def _env_float(name: str, default: float) -> float:
# Ephemeral run-key TTL backstop — must exceed the max wait so a key never
# expires mid-run; revocation still happens promptly when the run finishes.
_RUN_KEY_TTL_SECONDS = _env_float("LLC_RUN_KEY_TTL_SECONDS", _ADAPTER_MAX_WAIT_SECONDS + 600.0)
# A run is still in flight while in one of these states; everything else is terminal.
_NONTERMINAL_STATUSES = frozenset({LLCRunStatus.QUEUED, LLCRunStatus.RUNNING})


class HeartbeatScheduler:
Expand Down Expand Up @@ -801,7 +799,7 @@ async def _await_adapter_completion(adapter: Any, agent_config: Dict[str, Any],
started = time.monotonic()
while time.monotonic() - started < _ADAPTER_MAX_WAIT_SECONDS:
result = await adapter.status(agent_config, run_id)
if result.status not in _NONTERMINAL_STATUSES:
if result.status.is_terminal():
return result.status
await asyncio.sleep(_ADAPTER_POLL_INTERVAL)

Expand Down
79 changes: 79 additions & 0 deletions autobot-backend/llc/tests/test_agent_auth_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# AutoBot - AI-Powered Automation Platform
# Copyright (c) 2025 mrveiss
# Author: mrveiss
"""Tests for LLCAgentAuthMiddleware (GH#9777 dedup — delegates to ApiKeyService)."""

from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llc.middleware.agent_auth import LLCAgentAuthMiddleware

_HBS = "llc.middleware.agent_auth"


def _request(path: str, auth: str | None) -> MagicMock:
req = MagicMock()
req.url.path = path
req.headers = {"Authorization": auth} if auth is not None else {}
req.state = MagicMock()
return req


def _patched_get_factory():
"""Stand-in for get_async_session_factory: returns a factory whose call
yields a fresh async-context-manager session (middleware calls factory()
once per DB op)."""

@asynccontextmanager
async def _cm():
yield AsyncMock()

def factory():
return _cm()

return factory


@pytest.mark.asyncio
class TestAgentAuthMiddleware:
async def _dispatch(self, request):
mw = LLCAgentAuthMiddleware(app=MagicMock())
call_next = AsyncMock(return_value="OK")
return await mw.dispatch(request, call_next), call_next

async def test_passthrough_non_agent_path(self):
resp, call_next = await self._dispatch(_request("/api/other", None))
assert resp == "OK"
call_next.assert_awaited_once()

async def test_missing_bearer_401(self):
resp, call_next = await self._dispatch(_request("/api/llc/agent/context/1", None))
assert resp.status_code == 401
call_next.assert_not_awaited()

async def test_invalid_key_401_via_validate_key(self):
with (
patch(f"{_HBS}.get_async_session_factory", new=_patched_get_factory),
patch(f"{_HBS}.ApiKeyService") as svc,
):
svc.return_value.validate_key = AsyncMock(return_value=None)
resp, call_next = await self._dispatch(_request("/api/llc/agent/context/1", "Bearer llc_bad"))
assert resp.status_code == 401
call_next.assert_not_awaited()
svc.return_value.validate_key.assert_awaited_once()

async def test_valid_key_sets_state_and_continues(self):
record = MagicMock(id="k1", agent_id="agent-1", company_id="co-1")
with (
patch(f"{_HBS}.get_async_session_factory", new=_patched_get_factory),
patch(f"{_HBS}.ApiKeyService") as svc,
):
svc.return_value.validate_key = AsyncMock(return_value=record)
req = _request("/api/llc/agent/context/1", "Bearer llc_good")
resp, call_next = await self._dispatch(req)
assert resp == "OK"
call_next.assert_awaited_once()
assert req.state.agent_id == "agent-1"
assert req.state.company_id == "co-1"
32 changes: 32 additions & 0 deletions autobot-backend/llc/tests/test_run_status_terminal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# AutoBot - AI-Powered Automation Platform
# Copyright (c) 2025 mrveiss
# Author: mrveiss
"""Tests for LLCRunStatus.is_terminal() (GH#9777)."""

from llc.models.enums import LLCRunStatus


def test_nonterminal_states() -> None:
assert LLCRunStatus.QUEUED.is_terminal() is False
assert LLCRunStatus.RUNNING.is_terminal() is False


def test_terminal_states() -> None:
for status in (
LLCRunStatus.COMPLETED,
LLCRunStatus.FAILED,
LLCRunStatus.INTERRUPTED,
LLCRunStatus.TIMEOUT,
LLCRunStatus.CANCELLED,
LLCRunStatus.RATE_LIMITED,
):
assert status.is_terminal() is True, status


def test_terminal_partition_is_total() -> None:
# Every status is exactly one of terminal / non-terminal.
nonterminal = {s for s in LLCRunStatus if not s.is_terminal()}
terminal = {s for s in LLCRunStatus if s.is_terminal()}
assert nonterminal == {LLCRunStatus.QUEUED, LLCRunStatus.RUNNING}
assert nonterminal | terminal == set(LLCRunStatus)
assert not (nonterminal & terminal)
Loading