Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Agent lifecycle management module."""

from .registry import AgentRegistry
from .registry import AgentRegistry, AgentStatus
from .executor import AgentExecutor
from .runtime import AgentRuntime
from .sandbox import AgentSandbox

__all__ = ["AgentRegistry", "AgentExecutor", "AgentRuntime", "AgentSandbox"]
__all__ = ["AgentRegistry", "AgentStatus", "AgentExecutor", "AgentRuntime", "AgentSandbox"]

# 2019-02-05T12:34:30 update

Expand Down
73 changes: 66 additions & 7 deletions src/agent/registry.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Agent Registry — Manages agent lifecycle and metadata."""

import json
import logging
import time
import uuid
from enum import Enum
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)


class AgentStatus(Enum):
PENDING = "pending"
Expand All @@ -14,6 +17,7 @@ class AgentStatus(Enum):
STOPPED = "stopped"
FAILED = "failed"
TERMINATED = "terminated"
DISABLED = "disabled"


class AgentRegistry:
Expand All @@ -22,18 +26,20 @@ def __init__(self, storage_backend: str = "memory"):
self._agents: Dict[str, Dict[str, Any]] = {}
self._index: Dict[str, List[str]] = {}

def register(self, name: str, agent_type: str, config: Optional[Dict] = None) -> str:
def register(self, name: str, agent_type: str, config: Optional[Dict] = None, workspace: Optional[str] = None) -> str:
agent_id = str(uuid.uuid4())
timestamp = time.time()
self._agents[agent_id] = {
"id": agent_id,
"name": name,
"type": agent_type,
"status": AgentStatus.PENDING.value,
"enabled": True,
"config": config or {},
"created_at": timestamp,
"updated_at": timestamp,
"version": "1.0.0",
"workspace": workspace or "default",
"metrics": {"tasks_completed": 0, "errors": 0, "uptime": 0},
}
group = agent_type.split(".")[0]
Expand All @@ -42,11 +48,26 @@ def register(self, name: str, agent_type: str, config: Optional[Dict] = None) ->
self._index[group].append(agent_id)
return agent_id

def get(self, agent_id: str) -> Optional[Dict[str, Any]]:
return self._agents.get(agent_id)

def list(self, status: Optional[AgentStatus] = None, group: Optional[str] = None) -> List[Dict[str, Any]]:
def get(self, agent_id: str, workspace: Optional[str] = None) -> Optional[Dict[str, Any]]:
agent = self._agents.get(agent_id)
if agent is None:
return None
if workspace is not None and agent.get("workspace") != workspace:
return None
return agent

def list(
self,
status: Optional[AgentStatus] = None,
group: Optional[str] = None,
include_disabled: bool = False,
workspace: Optional[str] = None,
) -> List[Dict[str, Any]]:
agents = self._agents.values()
if workspace is not None:
agents = [a for a in agents if a.get("workspace") == workspace]
if not include_disabled:
agents = [a for a in agents if a.get("enabled", True)]
if status:
agents = [a for a in agents if a["status"] == status.value]
if group:
Expand All @@ -70,8 +91,46 @@ def delete(self, agent_id: str) -> bool:
self._index[group].remove(agent_id)
return True

def count(self) -> int:
return len(self._agents)
def disable(self, agent_id: str) -> bool:
"""Disable an agent. Removes it from default capability discovery listings."""
if agent_id not in self._agents:
return False
agent = self._agents[agent_id]
if not agent.get("enabled", True):
return False # already disabled
agent["enabled"] = False
agent["status"] = AgentStatus.DISABLED.value
agent["updated_at"] = time.time()
logger.info("Disabled agent %s (%s)", agent_id, agent.get("name", ""))
return True

def enable(self, agent_id: str) -> bool:
"""Re-enable a disabled agent. Restores to PENDING status."""
if agent_id not in self._agents:
return False
agent = self._agents[agent_id]
if agent.get("enabled", True):
return False # already enabled
agent["enabled"] = True
agent["status"] = AgentStatus.PENDING.value
agent["updated_at"] = time.time()
logger.info("Enabled agent %s (%s)", agent_id, agent.get("name", ""))
return True

def count(self, include_disabled: bool = False, workspace: Optional[str] = None) -> int:
agents = self._agents.values()
if workspace is not None:
agents = [a for a in agents if a.get("workspace") == workspace]
if not include_disabled:
agents = [a for a in agents if a.get("enabled", True)]
return len(agents)

def get_enabled(self, workspace: Optional[str] = None) -> List[Dict[str, Any]]:
"""Return all enabled agents (for capability discovery), optionally scoped to workspace."""
agents = self._agents.values()
if workspace is not None:
agents = [a for a in agents if a.get("workspace") == workspace]
return [a for a in agents if a.get("enabled", True)]

# 2019-01-29T11:24:49 update

Expand Down
211 changes: 76 additions & 135 deletions src/api/middleware.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,91 @@
"""API middleware components."""
"""API middleware components with session-aware auth and workspace scoping."""

import time
import logging
from typing import Callable
from typing import Callable, List, Optional

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

from .session import (
SessionRole,
get_session_store,
)

logger = logging.getLogger(__name__)

# Paths that don't require authentication
PUBLIC_PATHS = {
"/api/v2/auth/token",
"/api/v2/auth/refresh",
"/health",
"/api/docs",
"/api/redoc",
"/openapi.json",
}

# Paths requiring specific workspace roles
ROLE_REQUIRED_PATHS = {
"/api/v2/agents": [SessionRole.VIEWER, SessionRole.OPERATOR, SessionRole.ADMIN],
"/api/v2/agents/": [SessionRole.VIEWER, SessionRole.OPERATOR, SessionRole.ADMIN],
}


class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Response:
if request.url.path.startswith("/api/v2") and request.url.path != "/api/v2/auth/token":
token = request.headers.get("Authorization", "")
if not token.startswith("Bearer "):
return Response(status_code=401, content="Unauthorized")
return await call_next(request)
path = request.url.path

# Allow public paths
if path in PUBLIC_PATHS:
return await call_next(request)

# Only protect /api/v2 paths
if not path.startswith("/api/v2"):
return await call_next(request)

auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return Response(
status_code=401,
content='{"error":"Unauthorized","detail":"Missing or malformed Authorization header"}',
media_type="application/json",
)

token = auth_header[7:] # Strip "Bearer "
store = get_session_store()
session = store.validate_access_token(token)

if session is None:
return Response(
status_code=401,
content='{"error":"Unauthorized","detail":"Token is invalid, expired, or has been rotated out"}',
media_type="application/json",
)

# Check role-based access for protected paths
required_roles = self._get_required_roles(path)
if required_roles:
if not any(session.has_role(r) for r in required_roles):
return Response(
status_code=403,
content='{"error":"Forbidden","detail":"Insufficient workspace role"}',
media_type="application/json",
)

# Attach session info to request state for downstream handlers
request.state.session = session
response = await call_next(request)
return response

def _get_required_roles(self, path: str) -> Optional[List[SessionRole]]:
"""Get required roles for a path, matching by prefix."""
if path in ROLE_REQUIRED_PATHS:
return ROLE_REQUIRED_PATHS[path]
for prefix, roles in ROLE_REQUIRED_PATHS.items():
if path.startswith(prefix):
return roles
return None


class RateLimitMiddleware(BaseHTTPMiddleware):
Expand Down Expand Up @@ -49,131 +118,3 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response:
duration = time.time() - start
logger.info(f"{request.method} {request.url.path} {response.status_code} {duration:.3f}s")
return response

# 2019-03-01T18:35:19 update

# 2019-04-03T13:22:05 update

# 2019-04-30T17:18:49 update

# 2019-08-20T09:29:03 update

# 2019-08-30T15:52:06 update

# 2019-11-23T16:58:42 update

# 2020-02-18T10:04:07 update

# 2020-04-21T17:35:30 update

# 2020-05-22T11:10:34 update

# 2020-07-02T12:31:26 update

# 2020-07-05T13:52:59 update

# 2020-08-21T20:36:45 update

# 2021-01-19T09:17:15 update

# 2021-01-29T11:34:24 update

# 2021-02-04T15:21:21 update

# 2021-04-19T19:23:15 update

# 2021-05-20T16:50:15 update

# 2021-06-22T19:23:44 update

# 2021-09-09T13:44:55 update

# 2021-09-16T09:30:20 update

# 2021-10-14T20:42:33 update

# 2021-12-28T16:39:14 update

# 2022-01-26T19:07:27 update

# 2022-01-28T08:03:41 update

# 2022-03-23T12:17:02 update

# 2022-04-06T12:12:27 update

# 2022-04-21T14:53:01 update

# 2022-06-30T08:37:32 update

# 2022-07-06T10:44:45 update

# 2022-11-02T11:12:47 update

# 2022-11-15T20:54:21 update

# 2022-11-23T14:13:34 update

# 2023-01-26T10:03:44 update

# 2023-02-09T17:08:10 update

# 2023-02-16T10:04:00 update

# 2023-03-14T11:52:03 update

# 2023-04-10T12:42:07 update

# 2023-04-26T10:43:39 update

# 2023-06-27T08:18:07 update

# 2023-08-30T15:30:40 update

# 2023-08-30T14:10:05 update

# 2023-10-09T18:32:46 update

# 2023-11-21T20:35:55 update

# 2024-03-07T19:17:39 update

# 2024-04-01T18:06:19 update

# 2024-07-18T15:37:34 update

# 2024-07-25T09:21:53 update

# 2024-08-12T14:24:22 update

# 2024-11-18T08:50:54 update

# 2025-04-08T12:43:05 update

# 2025-06-03T08:10:47 update

# 2025-06-12T08:37:52 update

# 2025-06-17T08:36:56 update

# 2025-07-02T18:09:42 update

# 2025-07-22T12:39:21 update

# 2025-10-13T12:13:46 update

# 2025-12-05T09:44:22 update

# 2025-12-22T18:34:47 update

# 2026-01-26T15:36:23 update

# 2026-02-13T12:36:40 update

# 2026-02-26T11:07:15 update

# 2026-03-19T11:00:17 update

# 2026-03-27T12:58:53 update

# 2026-05-12T17:19:36 update
Loading
Loading