# Python imports import os from urllib.parse import quote # Third party imports import requests from django.core.exceptions import ValidationError from rest_framework import status from rest_framework.response import Response # Module imports from plane.app.permissions import ROLE, allow_permission from plane.app.views.base import BaseAPIView from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy from plane.db.models import Project, ProjectMember, Workspace, WorkspaceMember def get_gateway_config(): base_url = ( os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_URL", "").strip() or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_URL", "").strip() or os.environ.get("NODEDC_AGENT_GATEWAY_URL", "").strip() ).rstrip("/") token = ( os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TOKEN", "").strip() or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN", "").strip() ) timeout = float(os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS", "5") or "5") return base_url, token, timeout def owner_path(user): return quote(str(user.id), safe="") def agent_path(agent_id): return quote(str(agent_id), safe="") def token_path(token_id): return quote(str(token_id), safe="") def require_gateway_config(): base_url, token, timeout = get_gateway_config() if not base_url or not token: return None, Response( { "ok": False, "error": "codex_agent_gateway_not_configured", "message": "NODE.DC Codex Agent Gateway URL/token is not configured.", }, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) return (base_url, token, timeout), None def require_codex_agent_entitlement(user, slug): workspace_policy = get_nodedc_workspace_creation_policy(user, workspace_slug=slug) service_modules = workspace_policy.get("service_modules") or {} if not service_modules.get("codex_agents"): return None, Response( { "ok": False, "error": "codex_agents_not_entitled", "message": "Codex Agent API is not enabled for this NODE.DC user/workspace.", }, status=status.HTTP_403_FORBIDDEN, ) return workspace_policy, None def require_workspace(slug): try: return Workspace.objects.get(slug=slug), None except Workspace.DoesNotExist: return None, Response( {"ok": False, "error": "workspace_not_found"}, status=status.HTTP_404_NOT_FOUND, ) def is_workspace_admin(user, workspace): return WorkspaceMember.objects.filter( workspace=workspace, member=user, role=ROLE.ADMIN.value, is_active=True, is_banned=False, deleted_at__isnull=True, ).exists() def validate_project_in_workspace(workspace, project_id, user): workspace_admin = is_workspace_admin(user, workspace) if not project_id: if not workspace_admin: return Response( { "ok": False, "error": "project_required", "message": "Workspace members must select a concrete project for Codex Agent grants.", }, status=status.HTTP_400_BAD_REQUEST, ) return None try: project = Project.objects.filter( id=project_id, workspace=workspace, archived_at__isnull=True, deleted_at__isnull=True, ).first() except ValidationError: project = None if project is None: return Response( { "ok": False, "error": "project_not_found", "message": "Project is not available in this workspace.", }, status=status.HTTP_404_NOT_FOUND, ) if workspace_admin: return None if not ProjectMember.objects.filter( project=project, member=user, role__gte=ROLE.MEMBER.value, is_active=True, deleted_at__isnull=True, ).exists(): return Response( { "ok": False, "error": "project_access_denied", "message": "Codex Agent grants are limited to projects where this user is an active project member.", }, status=status.HTTP_403_FORBIDDEN, ) return None def validate_projects_in_workspace(workspace, project_ids, user): if not isinstance(project_ids, list) or len(project_ids) == 0: return Response( { "ok": False, "error": "project_ids_required", "message": "Select at least one project for Codex Agent grants.", }, status=status.HTTP_400_BAD_REQUEST, ) normalized_project_ids = [] for project_id in project_ids: project_id = str(project_id or "").strip() if not project_id or project_id in normalized_project_ids: continue project_error = validate_project_in_workspace(workspace, project_id, user) if project_error is not None: return project_error normalized_project_ids.append(project_id) if not normalized_project_ids: return Response( { "ok": False, "error": "project_ids_required", "message": "Select at least one project for Codex Agent grants.", }, status=status.HTTP_400_BAD_REQUEST, ) return normalized_project_ids def get_accessible_projects_for_workspace(workspace, user): queryset = Project.objects.filter( workspace=workspace, archived_at__isnull=True, deleted_at__isnull=True, ) if is_workspace_admin(user, workspace): return queryset.order_by("name") project_ids = ProjectMember.objects.filter( workspace=workspace, member=user, role__gte=ROLE.MEMBER.value, is_active=True, deleted_at__isnull=True, project__archived_at__isnull=True, project__deleted_at__isnull=True, ).values_list("project_id", flat=True) return queryset.filter(id__in=project_ids).order_by("name") def serialize_accessible_project(project): return { "id": str(project.id), "workspace_slug": project.workspace.slug, "name": project.name, "identifier": project.identifier, } def serialize_accessible_workspace(workspace_member, projects): workspace = workspace_member.workspace return { "id": str(workspace.id), "slug": workspace.slug, "name": workspace.name, "role": workspace_member.role, "projects": [serialize_accessible_project(project) for project in projects], } def normalize_project_grants(raw_grants): if not isinstance(raw_grants, list) or len(raw_grants) == 0: return Response( { "ok": False, "error": "grants_required", "message": "Select at least one workspace/project grant.", }, status=status.HTTP_400_BAD_REQUEST, ) normalized_grants = [] seen_keys = set() for raw_grant in raw_grants: if not isinstance(raw_grant, dict): continue workspace_slug = str(raw_grant.get("workspace_slug") or "").strip() project_id = str(raw_grant.get("project_id") or "").strip() if not workspace_slug or not project_id: continue grant_key = f"{workspace_slug}:{project_id}" if grant_key in seen_keys: continue seen_keys.add(grant_key) normalized_grants.append( { "workspace_slug": workspace_slug, "project_id": project_id, } ) if not normalized_grants: return Response( { "ok": False, "error": "grants_required", "message": "Select at least one workspace/project grant.", }, status=status.HTTP_400_BAD_REQUEST, ) return normalized_grants def validate_project_grants(grants, user): for grant in grants: workspace, workspace_error = require_workspace(grant["workspace_slug"]) if workspace_error is not None: return workspace_error _, entitlement_error = require_codex_agent_entitlement(user, workspace.slug) if entitlement_error is not None: return entitlement_error project_error = validate_project_in_workspace(workspace, grant["project_id"], user) if project_error is not None: return project_error return None def gateway_request(method, path, payload=None): config, error_response = require_gateway_config() if error_response is not None: return error_response base_url, token, timeout = config try: response = requests.request( method, f"{base_url}{path}", headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", }, json=payload, timeout=timeout, ) except requests.RequestException: return Response( { "ok": False, "error": "codex_agent_gateway_unavailable", "message": "NODE.DC Codex Agent Gateway is unavailable.", }, status=status.HTTP_502_BAD_GATEWAY, ) try: data = response.json() except ValueError: data = { "ok": False, "error": "codex_agent_gateway_invalid_response", "message": "NODE.DC Codex Agent Gateway returned a non-JSON response.", } return Response(data, status=response.status_code) class CodexAgentEntitledEndpoint(BaseAPIView): def require_entitlement(self, request, slug): _, entitlement_error = require_codex_agent_entitlement(request.user, slug) return entitlement_error class CodexAgentListEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error display_name = str(request.data.get("display_name") or "").strip() if not display_name: return Response( {"ok": False, "error": "display_name_required"}, status=status.HTTP_400_BAD_REQUEST, ) payload = { "display_name": display_name, "owner_email": request.user.email or None, "avatar_url": request.data.get("avatar_url") or None, } return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents", payload) class CodexAgentProjectAccessEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error workspace_members = ( WorkspaceMember.objects.filter( member=request.user, is_active=True, is_banned=False, deleted_at__isnull=True, workspace__deleted_at__isnull=True, ) .select_related("workspace") .order_by("workspace__name") ) workspaces = [] for workspace_member in workspace_members: workspace = workspace_member.workspace _, workspace_entitlement_error = require_codex_agent_entitlement(request.user, workspace.slug) if workspace_entitlement_error is not None: continue projects = list(get_accessible_projects_for_workspace(workspace, request.user)) workspaces.append(serialize_accessible_workspace(workspace_member, projects)) return Response({"ok": True, "workspaces": workspaces}) class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def patch(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error payload = {"actor_user_id": str(request.user.id)} if "display_name" in request.data: payload["display_name"] = request.data.get("display_name") if "avatar_url" in request.data: payload["avatar_url"] = request.data.get("avatar_url") or None return gateway_request("PATCH", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}", payload) class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request( "POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/revoke", {"actor_user_id": str(request.user.id)}, ) class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error workspace, workspace_error = require_workspace(slug) if workspace_error is not None: return workspace_error project_id = request.data.get("project_id") project_error = validate_project_in_workspace(workspace, project_id, request.user) if project_error is not None: return project_error payload = { "workspace_slug": slug, "project_id": str(project_id) if project_id else None, "scopes": request.data.get("scopes") or [], "mode": request.data.get("mode") or "voluntary", } return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants", payload) class CodexAgentGrantReplaceEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error workspace, workspace_error = require_workspace(slug) if workspace_error is not None: return workspace_error project_ids_or_error = validate_projects_in_workspace(workspace, request.data.get("project_ids"), request.user) if isinstance(project_ids_or_error, Response): return project_ids_or_error payload = { "workspace_slug": slug, "project_ids": project_ids_or_error, "scopes": request.data.get("scopes") or [], "mode": request.data.get("mode") or "voluntary", } return gateway_request( "POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants/replace", payload, ) class CodexAgentGrantReplaceProjectsEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error grants_or_error = normalize_project_grants(request.data.get("grants")) if isinstance(grants_or_error, Response): return grants_or_error validation_response = validate_project_grants(grants_or_error, request.user) if validation_response is not None: return validation_response payload = { "grants": grants_or_error, "scopes": request.data.get("scopes") or [], "mode": request.data.get("mode") or "voluntary", } return gateway_request( "POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants/replace-projects", payload, ) class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error payload = { "name": request.data.get("name") or "Local Codex token", "expires_at": request.data.get("expires_at") or None, } return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens", payload) class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug, agent_id, token_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request( "POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens/{token_path(token_id)}/revoke", {"actor_user_id": str(request.user.id)}, ) class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, agent_id): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: return entitlement_error return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")