FEAT - TASKER: управление Codex Agent API через Gateway proxy
This commit is contained in:
parent
97566faba3
commit
3b8e0ea594
|
|
@ -79,6 +79,9 @@ x-app-env: &app-env
|
||||||
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3}
|
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3}
|
||||||
PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check}
|
PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check}
|
||||||
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3}
|
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3}
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5}
|
||||||
GUNICORN_WORKERS: 1
|
GUNICORN_WORKERS: 1
|
||||||
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
||||||
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,6 @@ PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consu
|
||||||
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
|
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
|
||||||
PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check
|
PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check
|
||||||
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
|
||||||
|
|
|
||||||
|
|
@ -94,4 +94,7 @@ PLANE_NODEDC_HANDOFF_URL=https://launcher.staging.nodedc.example/api/internal/ha
|
||||||
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
|
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
|
||||||
PLANE_NODEDC_WORKSPACE_POLICY_URL=https://launcher.staging.nodedc.example/api/internal/access/check
|
PLANE_NODEDC_WORKSPACE_POLICY_URL=https://launcher.staging.nodedc.example/api/internal/access/check
|
||||||
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_URL=https://codex-api.staging.nodedc.example
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
|
||||||
|
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
|
||||||
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user
|
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
from .analytic import urlpatterns as analytic_urls
|
from .analytic import urlpatterns as analytic_urls
|
||||||
from .api import urlpatterns as api_urls
|
from .api import urlpatterns as api_urls
|
||||||
from .asset import urlpatterns as asset_urls
|
from .asset import urlpatterns as asset_urls
|
||||||
|
from .codex_agents import urlpatterns as codex_agent_urls
|
||||||
from .cycle import urlpatterns as cycle_urls
|
from .cycle import urlpatterns as cycle_urls
|
||||||
from .estimate import urlpatterns as estimate_urls
|
from .estimate import urlpatterns as estimate_urls
|
||||||
from .external import urlpatterns as external_urls
|
from .external import urlpatterns as external_urls
|
||||||
|
|
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
*analytic_urls,
|
*analytic_urls,
|
||||||
*asset_urls,
|
*asset_urls,
|
||||||
|
*codex_agent_urls,
|
||||||
*cycle_urls,
|
*cycle_urls,
|
||||||
*estimate_urls,
|
*estimate_urls,
|
||||||
*external_urls,
|
*external_urls,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from plane.app.views import (
|
||||||
|
CodexAgentDetailEndpoint,
|
||||||
|
CodexAgentGrantListEndpoint,
|
||||||
|
CodexAgentListEndpoint,
|
||||||
|
CodexAgentRevokeEndpoint,
|
||||||
|
CodexAgentSetupEndpoint,
|
||||||
|
CodexAgentTokenListEndpoint,
|
||||||
|
CodexAgentTokenRevokeEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/",
|
||||||
|
CodexAgentListEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agents",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/",
|
||||||
|
CodexAgentDetailEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agent-detail",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/revoke/",
|
||||||
|
CodexAgentRevokeEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agent-revoke",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/grants/",
|
||||||
|
CodexAgentGrantListEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agent-grants",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/",
|
||||||
|
CodexAgentTokenListEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agent-tokens",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/<uuid:token_id>/revoke/",
|
||||||
|
CodexAgentTokenRevokeEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agent-token-revoke",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/setup/",
|
||||||
|
CodexAgentSetupEndpoint.as_view(),
|
||||||
|
name="codex-agent-api-agent-setup",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -174,6 +174,16 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||||
|
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
|
from .codex_agents import (
|
||||||
|
CodexAgentDetailEndpoint,
|
||||||
|
CodexAgentGrantListEndpoint,
|
||||||
|
CodexAgentListEndpoint,
|
||||||
|
CodexAgentRevokeEndpoint,
|
||||||
|
CodexAgentSetupEndpoint,
|
||||||
|
CodexAgentTokenListEndpoint,
|
||||||
|
CodexAgentTokenRevokeEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .page.base import (
|
from .page.base import (
|
||||||
PageViewSet,
|
PageViewSet,
|
||||||
PageFavoriteViewSet,
|
PageFavoriteViewSet,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
# 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, Workspace
|
||||||
|
|
||||||
|
|
||||||
|
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 validate_project_in_workspace(workspace, project_id):
|
||||||
|
if not project_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
exists = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).exists()
|
||||||
|
except ValidationError:
|
||||||
|
exists = False
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "project_not_found",
|
||||||
|
"message": "Project is not available in this workspace.",
|
||||||
|
},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
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], 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], 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 CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN], 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], 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], 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], 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], 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)
|
||||||
|
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 CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN], 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], 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], 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], 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")
|
||||||
|
|
@ -4,16 +4,40 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react";
|
import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@plane/propel/button";
|
||||||
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
// components
|
// components
|
||||||
import { SettingsHeading } from "@/components/settings/heading";
|
import { SettingsHeading } from "@/components/settings/heading";
|
||||||
// hooks
|
// hooks
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
// services
|
// services
|
||||||
|
import { ProjectService } from "@/services/project/project.service";
|
||||||
|
import {
|
||||||
|
WorkspaceCodexAgentService,
|
||||||
|
type TCodexAgentCreateTokenResponse,
|
||||||
|
} from "@/services/workspace-codex-agent.service";
|
||||||
import { WorkspaceService } from "@/services/workspace.service";
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
|
|
||||||
|
const TASK_AUTHOR_SCOPES = [
|
||||||
|
"workspace:read",
|
||||||
|
"project:read",
|
||||||
|
"project:member:add_existing",
|
||||||
|
"issue:read",
|
||||||
|
"issue:create",
|
||||||
|
"issue:update",
|
||||||
|
"issue:move",
|
||||||
|
"issue:comment",
|
||||||
|
"issue:label",
|
||||||
|
"issue:assign",
|
||||||
|
"issue:structured_blocks:write",
|
||||||
|
];
|
||||||
|
|
||||||
|
const codexAgentService = new WorkspaceCodexAgentService();
|
||||||
|
const projectService = new ProjectService();
|
||||||
const workspaceService = new WorkspaceService();
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
||||||
type TProps = {
|
type TProps = {
|
||||||
|
|
@ -23,12 +47,80 @@ type TProps = {
|
||||||
|
|
||||||
export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
|
export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
|
||||||
const { showHeading = true, workspaceSlug } = props;
|
const { showHeading = true, workspaceSlug } = props;
|
||||||
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||||
|
const [newAgentName, setNewAgentName] = useState("Local Codex");
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||||
|
const [tokenPacket, setTokenPacket] = useState<TCodexAgentCreateTokenResponse | null>(null);
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { data: nodedcWorkspacePolicy, isLoading } = useSWR(
|
const { data: nodedcWorkspacePolicy, isLoading } = useSWR(
|
||||||
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
|
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
|
||||||
() => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
|
() => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
|
||||||
);
|
);
|
||||||
const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
|
const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
|
||||||
|
const {
|
||||||
|
data: codexAgentsPayload,
|
||||||
|
error: codexAgentsError,
|
||||||
|
isLoading: areAgentsLoading,
|
||||||
|
mutate: mutateCodexAgents,
|
||||||
|
} = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () =>
|
||||||
|
codexAgentService.listAgents(workspaceSlug)
|
||||||
|
);
|
||||||
|
const { data: projects } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECTS_${workspaceSlug}` : null, () =>
|
||||||
|
projectService.getProjectsLite(workspaceSlug)
|
||||||
|
);
|
||||||
|
const activeAgents = useMemo(
|
||||||
|
() => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
|
||||||
|
[codexAgentsPayload?.agents]
|
||||||
|
);
|
||||||
|
const projectOptions = projects ?? [];
|
||||||
|
const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || "";
|
||||||
|
|
||||||
|
const handleCopy = async (value: string, label: string) => {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: `${label} скопирован`,
|
||||||
|
message: "Секреты не сохраняются в TASKER_AGENT.md; token нужно хранить отдельно в локальном Codex.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAgent = async () => {
|
||||||
|
const displayName = newAgentName.trim();
|
||||||
|
if (!displayName || !effectiveSelectedProjectId) return;
|
||||||
|
|
||||||
|
setIsCreatingAgent(true);
|
||||||
|
try {
|
||||||
|
const createResponse = await codexAgentService.createAgent(workspaceSlug, { display_name: displayName });
|
||||||
|
await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
|
||||||
|
project_id: effectiveSelectedProjectId,
|
||||||
|
scopes: TASK_AUTHOR_SCOPES,
|
||||||
|
mode: "voluntary",
|
||||||
|
});
|
||||||
|
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
|
||||||
|
name: `${displayName} local token`,
|
||||||
|
});
|
||||||
|
setTokenPacket(tokenResponse);
|
||||||
|
await mutateCodexAgents();
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Codex agent создан",
|
||||||
|
message: "Token показан один раз. Скопируйте его в локальную конфигурацию Codex.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Не удалось создать Codex agent",
|
||||||
|
message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный проект.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreatingAgent(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeAgent = async (agentId: string) => {
|
||||||
|
await codexAgentService.revokeAgent(workspaceSlug, agentId);
|
||||||
|
await mutateCodexAgents();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-7">
|
<div className="flex w-full flex-col gap-7">
|
||||||
|
|
@ -52,7 +144,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 max-w-3xl text-13 leading-5 text-secondary">
|
<p className="mt-2 max-w-3xl text-13 leading-5 text-secondary">
|
||||||
Доступ к модулю приходит из Launcher entitlement Operational Core → Codex Agent API. Если entitlement
|
Доступ к модулю приходит из Launcher entitlement Operational Core → Codex Agent API. Если entitlement
|
||||||
снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный модуль.
|
снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный
|
||||||
|
модуль.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="nodedc-external-readonly-value shrink-0">
|
<div className="nodedc-external-readonly-value shrink-0">
|
||||||
|
|
@ -81,6 +174,145 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
|
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isCodexAgentEntitled && (
|
||||||
|
<section className="nodedc-settings-card px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-15 font-semibold text-primary">Агенты workspace</div>
|
||||||
|
<p className="mt-1 max-w-3xl text-13 leading-5 text-secondary">
|
||||||
|
Tasker вызывает Agent Gateway только через backend proxy. Frontend не получает сервисный Gateway
|
||||||
|
token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)_auto]">
|
||||||
|
<input
|
||||||
|
className="nodedc-settings-input border-custom-border-200 bg-custom-background-100 h-10 min-w-0 rounded-lg border px-3 text-13 text-primary outline-none"
|
||||||
|
value={newAgentName}
|
||||||
|
onChange={(event) => setNewAgentName(event.target.value)}
|
||||||
|
placeholder="Имя агента"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="nodedc-settings-input border-custom-border-200 bg-custom-background-100 h-10 min-w-0 rounded-lg border px-3 text-13 text-primary outline-none"
|
||||||
|
value={effectiveSelectedProjectId}
|
||||||
|
onChange={(event) => setSelectedProjectId(event.target.value)}
|
||||||
|
>
|
||||||
|
{projectOptions.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="nodedc-settings-save-button min-w-[11rem]"
|
||||||
|
disabled={!newAgentName.trim() || !effectiveSelectedProjectId}
|
||||||
|
loading={isCreatingAgent}
|
||||||
|
onClick={handleCreateAgent}
|
||||||
|
>
|
||||||
|
Создать агента
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{codexAgentsError && (
|
||||||
|
<div className="border-red-500/30 bg-red-500/10 text-red-300 mt-4 rounded-xl border px-4 py-3 text-13">
|
||||||
|
Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и
|
||||||
|
`PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{areAgentsLoading ? (
|
||||||
|
<div className="text-13 text-secondary">Загрузка агентов...</div>
|
||||||
|
) : activeAgents.length > 0 ? (
|
||||||
|
activeAgents.map((agent) => (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className="border-custom-border-200 bg-custom-background-90 flex flex-col gap-3 rounded-xl border px-4 py-3 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-14 font-semibold text-primary">{agent.display_name}</div>
|
||||||
|
<div className="mt-1 text-12 text-secondary">
|
||||||
|
status: {agent.status} · created: {new Date(agent.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip"
|
||||||
|
onClick={() => void handleRevokeAgent(agent.id)}
|
||||||
|
>
|
||||||
|
Отозвать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="border-custom-border-200 rounded-xl border border-dashed px-4 py-6 text-center text-13 text-secondary">
|
||||||
|
Агентов пока нет. Создайте агента, выберите проект и сразу получите token + TASKER_AGENT.md.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tokenPacket && (
|
||||||
|
<section className="nodedc-settings-card px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-15 font-semibold text-primary">Одноразовый token и setup packet</div>
|
||||||
|
<p className="mt-1 text-13 text-secondary">
|
||||||
|
Token больше не будет доступен после закрытия этого блока. Markdown-инструкция не содержит секрет.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip"
|
||||||
|
onClick={() => setTokenPacket(null)}
|
||||||
|
>
|
||||||
|
Скрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4">
|
||||||
|
<div className="border-custom-border-200 bg-custom-background-90 rounded-xl border p-4">
|
||||||
|
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">Agent token</div>
|
||||||
|
<code className="bg-custom-background-80 block rounded-lg px-3 py-2 text-12 break-all text-primary">
|
||||||
|
{tokenPacket.token}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip mt-3"
|
||||||
|
onClick={() => void handleCopy(tokenPacket.token, "Agent token")}
|
||||||
|
>
|
||||||
|
Copy token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{tokenPacket.setup?.agents_md && (
|
||||||
|
<div className="border-custom-border-200 bg-custom-background-90 rounded-xl border p-4">
|
||||||
|
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
|
||||||
|
TASKER_AGENT.md
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
className="border-custom-border-200 bg-custom-background-80 font-mono h-64 w-full resize-y rounded-lg border px-3 py-2 text-12 text-primary outline-none"
|
||||||
|
value={tokenPacket.setup.agents_md}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip mt-3"
|
||||||
|
onClick={() => void handleCopy(tokenPacket.setup?.agents_md ?? "", "TASKER_AGENT.md")}
|
||||||
|
>
|
||||||
|
Copy TASKER_AGENT.md
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@plane/constants";
|
||||||
|
import { APIService } from "@/services/api.service";
|
||||||
|
|
||||||
|
export type TCodexAgentStatus = "active" | "disabled" | "revoked";
|
||||||
|
export type TCodexAgentGrantMode = "voluntary" | "reporting";
|
||||||
|
export type TCodexAgentTokenStatus = "active" | "revoked" | "expired";
|
||||||
|
|
||||||
|
export type TCodexAgent = {
|
||||||
|
id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
owner_email: string | null;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
status: TCodexAgentStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentGrant = {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
workspace_slug: string;
|
||||||
|
project_id: string | null;
|
||||||
|
scopes: string[];
|
||||||
|
mode: TCodexAgentGrantMode;
|
||||||
|
created_by_user_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentToken = {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
name: string;
|
||||||
|
status: TCodexAgentTokenStatus;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentSetupPacket = {
|
||||||
|
agents_md?: string;
|
||||||
|
codex_config_template?: Record<string, unknown>;
|
||||||
|
mcp_server?: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentListResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
agents: TCodexAgent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentCreateTokenResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
setup?: TCodexAgentSetupPacket;
|
||||||
|
token: string;
|
||||||
|
token_record: TCodexAgentToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WorkspaceCodexAgentService extends APIService {
|
||||||
|
constructor() {
|
||||||
|
super(API_BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAgents(workspaceSlug: string): Promise<TCodexAgentListResponse> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAgent(
|
||||||
|
workspaceSlug: string,
|
||||||
|
data: { display_name: string; avatar_url?: string | null }
|
||||||
|
): Promise<{ ok: boolean; agent: TCodexAgent }> {
|
||||||
|
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertGrant(
|
||||||
|
workspaceSlug: string,
|
||||||
|
agentId: string,
|
||||||
|
data: {
|
||||||
|
mode?: TCodexAgentGrantMode;
|
||||||
|
project_id?: string | null;
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
): Promise<{ ok: boolean; grant: TCodexAgentGrant }> {
|
||||||
|
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/grants/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken(
|
||||||
|
workspaceSlug: string,
|
||||||
|
agentId: string,
|
||||||
|
data: { name?: string }
|
||||||
|
): Promise<TCodexAgentCreateTokenResponse> {
|
||||||
|
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAgent(workspaceSlug: string, agentId: string): Promise<{ ok: boolean; agent: TCodexAgent }> {
|
||||||
|
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/revoke/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue