SEC - TASKER: enforce workspace-scoped agent access
This commit is contained in:
parent
65ee15b86c
commit
b0a682b63b
|
|
@ -12,7 +12,7 @@ from rest_framework.response import Response
|
||||||
from plane.app.permissions import ROLE, allow_permission
|
from plane.app.permissions import ROLE, allow_permission
|
||||||
from plane.app.views.base import BaseAPIView
|
from plane.app.views.base import BaseAPIView
|
||||||
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
|
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
|
||||||
from plane.db.models import Project, Workspace
|
from plane.db.models import Project, ProjectMember, Workspace, WorkspaceMember
|
||||||
|
|
||||||
|
|
||||||
def get_gateway_config():
|
def get_gateway_config():
|
||||||
|
|
@ -80,16 +80,35 @@ def require_workspace(slug):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_project_in_workspace(workspace, project_id):
|
def is_workspace_admin(user, workspace):
|
||||||
|
return WorkspaceMember.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
member=user,
|
||||||
|
role=ROLE.ADMIN.value,
|
||||||
|
is_active=True,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_project_in_workspace(workspace, project_id, user):
|
||||||
|
workspace_admin = is_workspace_admin(user, workspace)
|
||||||
if not project_id:
|
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
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exists = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).exists()
|
project = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).first()
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
exists = False
|
project = None
|
||||||
|
|
||||||
if not exists:
|
if project is None:
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
@ -98,6 +117,25 @@ def validate_project_in_workspace(workspace, project_id):
|
||||||
},
|
},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
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,
|
||||||
|
).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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -147,7 +185,7 @@ class CodexAgentEntitledEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -155,7 +193,7 @@ class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents")
|
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents")
|
||||||
|
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -177,7 +215,7 @@ class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug, agent_id):
|
def get(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -185,7 +223,7 @@ class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}")
|
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")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def patch(self, request, slug, agent_id):
|
def patch(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -200,7 +238,7 @@ class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def post(self, request, slug, agent_id):
|
def post(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -214,7 +252,7 @@ class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug, agent_id):
|
def get(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -222,7 +260,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants")
|
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")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def post(self, request, slug, agent_id):
|
def post(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -233,7 +271,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
return workspace_error
|
return workspace_error
|
||||||
|
|
||||||
project_id = request.data.get("project_id")
|
project_id = request.data.get("project_id")
|
||||||
project_error = validate_project_in_workspace(workspace, project_id)
|
project_error = validate_project_in_workspace(workspace, project_id, request.user)
|
||||||
if project_error is not None:
|
if project_error is not None:
|
||||||
return project_error
|
return project_error
|
||||||
|
|
||||||
|
|
@ -247,7 +285,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug, agent_id):
|
def get(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -255,7 +293,7 @@ class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens")
|
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")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def post(self, request, slug, agent_id):
|
def post(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -269,7 +307,7 @@ class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def post(self, request, slug, agent_id, token_id):
|
def post(self, request, slug, agent_id, token_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
@ -283,7 +321,7 @@ class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||||
|
|
||||||
|
|
||||||
class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
|
class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug, agent_id):
|
def get(self, request, slug, agent_id):
|
||||||
entitlement_error = self.require_entitlement(request, slug)
|
entitlement_error = self.require_entitlement(request, slug)
|
||||||
if entitlement_error is not None:
|
if entitlement_error is not None:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -9,6 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from plane.app.realtime.issue_events import publish_issue_event_on_commit
|
from plane.app.realtime.issue_events import publish_issue_event_on_commit
|
||||||
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
|
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
|
||||||
|
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
|
||||||
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body
|
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Issue,
|
Issue,
|
||||||
|
|
@ -244,6 +246,33 @@ def validate_internal_request(request):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_agent_owner(request):
|
||||||
|
identity = get_agent_identity(request)
|
||||||
|
if identity is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return User.objects.filter(id=identity["owner_user_id"], is_active=True).first()
|
||||||
|
except (TypeError, ValueError, ValidationError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_agent_workspace_entitlement(request, workspace_slug):
|
||||||
|
if not workspace_slug:
|
||||||
|
return validation_error("workspace_slug_required")
|
||||||
|
|
||||||
|
owner = resolve_agent_owner(request)
|
||||||
|
if owner is None:
|
||||||
|
return validation_error("agent_owner_not_found", status=403)
|
||||||
|
|
||||||
|
workspace_policy = get_nodedc_workspace_creation_policy(owner, workspace_slug=workspace_slug)
|
||||||
|
service_modules = workspace_policy.get("service_modules") or {}
|
||||||
|
if service_modules.get("codex_agents") is not True:
|
||||||
|
return validation_error("codex_agents_not_entitled", status=403)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def html_from_text(value):
|
def html_from_text(value):
|
||||||
text = value.strip() if isinstance(value, str) else ""
|
text = value.strip() if isinstance(value, str) else ""
|
||||||
return f"<p>{escape(text)}</p>" if text else "<p></p>"
|
return f"<p>{escape(text)}</p>" if text else "<p></p>"
|
||||||
|
|
@ -319,6 +348,9 @@ class NodeDCAgentProjectResolveEndpoint(View):
|
||||||
project_id = grant.get("project_id")
|
project_id = grant.get("project_id")
|
||||||
if not isinstance(workspace_slug, str) or not workspace_slug:
|
if not isinstance(workspace_slug, str) or not workspace_slug:
|
||||||
continue
|
continue
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
continue
|
||||||
if isinstance(project_id, str) and project_id:
|
if isinstance(project_id, str) and project_id:
|
||||||
project_filters.append((workspace_slug, project_id))
|
project_filters.append((workspace_slug, project_id))
|
||||||
else:
|
else:
|
||||||
|
|
@ -358,6 +390,10 @@ class NodeDCAgentProjectContextEndpoint(View):
|
||||||
if project is None:
|
if project is None:
|
||||||
return validation_error("project_not_found", status=404)
|
return validation_error("project_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence")
|
states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence")
|
||||||
labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order")
|
labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order")
|
||||||
members = (
|
members = (
|
||||||
|
|
@ -392,6 +428,10 @@ class NodeDCAgentIssueListEndpoint(View):
|
||||||
if project is None:
|
if project is None:
|
||||||
return validation_error("project_not_found", status=404)
|
return validation_error("project_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
queryset = (
|
queryset = (
|
||||||
Issue.issue_objects.filter(project=project)
|
Issue.issue_objects.filter(project=project)
|
||||||
.select_related("workspace", "project", "state")
|
.select_related("workspace", "project", "state")
|
||||||
|
|
@ -416,6 +456,10 @@ class NodeDCAgentIssueListEndpoint(View):
|
||||||
if project is None:
|
if project is None:
|
||||||
return validation_error("project_not_found", status=404)
|
return validation_error("project_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
title = payload.get("title")
|
title = payload.get("title")
|
||||||
if not isinstance(title, str) or not title.strip():
|
if not isinstance(title, str) or not title.strip():
|
||||||
return validation_error("title_required")
|
return validation_error("title_required")
|
||||||
|
|
@ -466,6 +510,10 @@ class NodeDCAgentIssueUpdateEndpoint(View):
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return validation_error("issue_not_found", status=404)
|
return validation_error("issue_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
detail_layout = (
|
detail_layout = (
|
||||||
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
|
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
|
||||||
if "structured_blocks" in payload
|
if "structured_blocks" in payload
|
||||||
|
|
@ -519,6 +567,10 @@ class NodeDCAgentIssueMoveEndpoint(View):
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return validation_error("issue_not_found", status=404)
|
return validation_error("issue_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
|
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
|
||||||
if state is None:
|
if state is None:
|
||||||
return validation_error("state_not_found", status=404)
|
return validation_error("state_not_found", status=404)
|
||||||
|
|
@ -555,6 +607,10 @@ class NodeDCAgentIssueCommentEndpoint(View):
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return validation_error("issue_not_found", status=404)
|
return validation_error("issue_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
body = payload.get("body")
|
body = payload.get("body")
|
||||||
if not isinstance(body, str) or not body.strip():
|
if not isinstance(body, str) or not body.strip():
|
||||||
return validation_error("body_required")
|
return validation_error("body_required")
|
||||||
|
|
@ -592,6 +648,10 @@ class NodeDCAgentIssueLabelsEndpoint(View):
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return validation_error("issue_not_found", status=404)
|
return validation_error("issue_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
label_ids = payload.get("label_ids")
|
label_ids = payload.get("label_ids")
|
||||||
if not isinstance(label_ids, list):
|
if not isinstance(label_ids, list):
|
||||||
return validation_error("label_ids_required")
|
return validation_error("label_ids_required")
|
||||||
|
|
@ -625,6 +685,10 @@ class NodeDCAgentIssueAssigneesEndpoint(View):
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return validation_error("issue_not_found", status=404)
|
return validation_error("issue_not_found", status=404)
|
||||||
|
|
||||||
|
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
|
||||||
|
if entitlement_error is not None:
|
||||||
|
return entitlement_error
|
||||||
|
|
||||||
member_ids = payload.get("member_ids")
|
member_ids = payload.get("member_ids")
|
||||||
if not isinstance(member_ids, list):
|
if not isinstance(member_ids, list):
|
||||||
return validation_error("member_ids_required")
|
return validation_error("member_ids_required")
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { type ChangeEvent, useMemo, useRef, 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 { Button } from "@plane/propel/button";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
|
import { getFileURL } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
import { SettingsHeading } from "@/components/settings/heading";
|
import { SettingsHeading } from "@/components/settings/heading";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -18,7 +19,9 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
import { ProjectService } from "@/services/project/project.service";
|
import { ProjectService } from "@/services/project/project.service";
|
||||||
import {
|
import {
|
||||||
WorkspaceCodexAgentService,
|
WorkspaceCodexAgentService,
|
||||||
type TCodexAgentCreateTokenResponse,
|
type TCodexAgent,
|
||||||
|
type TCodexAgentSetupPacket,
|
||||||
|
type TCodexAgentToken,
|
||||||
} from "@/services/workspace-codex-agent.service";
|
} from "@/services/workspace-codex-agent.service";
|
||||||
import { WorkspaceService } from "@/services/workspace.service";
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
|
|
||||||
|
|
@ -36,6 +39,10 @@ const TASK_AUTHOR_SCOPES = [
|
||||||
"issue:structured_blocks:write",
|
"issue:structured_blocks:write",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
|
||||||
|
const MAX_AGENT_AVATAR_BYTES = 256 * 1024;
|
||||||
|
const OPS_AGENT_FILENAME = "OPS_AGENT.md";
|
||||||
|
|
||||||
const codexAgentService = new WorkspaceCodexAgentService();
|
const codexAgentService = new WorkspaceCodexAgentService();
|
||||||
const projectService = new ProjectService();
|
const projectService = new ProjectService();
|
||||||
const workspaceService = new WorkspaceService();
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
@ -45,12 +52,25 @@ type TProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TAgentSetupCard = {
|
||||||
|
agent: TCodexAgent;
|
||||||
|
setup?: TCodexAgentSetupPacket;
|
||||||
|
tokens: TCodexAgentToken[];
|
||||||
|
};
|
||||||
|
|
||||||
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 createAvatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const agentAvatarInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||||
const [newAgentName, setNewAgentName] = useState("Local Codex");
|
const [newAgentName, setNewAgentName] = useState("Local Codex");
|
||||||
|
const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState<string | null>(null);
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||||
const [tokenPacket, setTokenPacket] = useState<TCodexAgentCreateTokenResponse | null>(null);
|
const [agentDraftNames, setAgentDraftNames] = useState<Record<string, string>>({});
|
||||||
|
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
|
||||||
|
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
|
||||||
|
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
|
||||||
|
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
|
||||||
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,
|
||||||
|
|
@ -72,25 +92,106 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
() => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
|
() => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
|
||||||
[codexAgentsPayload?.agents]
|
[codexAgentsPayload?.agents]
|
||||||
);
|
);
|
||||||
|
const activeAgentIds = useMemo(() => activeAgents.map((agent) => agent.id).join(","), [activeAgents]);
|
||||||
|
const {
|
||||||
|
data: persistedSetupCards,
|
||||||
|
isLoading: areSetupCardsLoading,
|
||||||
|
mutate: mutateSetupCards,
|
||||||
|
} = useSWR(
|
||||||
|
isCodexAgentEntitled && activeAgentIds
|
||||||
|
? `CODEX_AGENT_API_AGENT_SETUP_CARDS_${workspaceSlug}_${activeAgentIds}`
|
||||||
|
: null,
|
||||||
|
async () =>
|
||||||
|
Promise.all(
|
||||||
|
activeAgents.map(async (agent) => {
|
||||||
|
const [tokensPayload, setupPayload] = await Promise.all([
|
||||||
|
codexAgentService.listTokens(workspaceSlug, agent.id),
|
||||||
|
codexAgentService.getSetup(workspaceSlug, agent.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
setup: setupPayload.setup,
|
||||||
|
tokens: tokensPayload.tokens.filter((token) => token.status === "active"),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
const projectOptions = projects ?? [];
|
const projectOptions = projects ?? [];
|
||||||
const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || "";
|
const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || "";
|
||||||
|
const setupCards = useMemo(
|
||||||
|
() => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
|
||||||
|
[createdSetupCards, persistedSetupCards]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCopy = async (value: string, label: string) => {
|
const handleCopy = async (value: string, label: string) => {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: `${label} скопирован`,
|
title: `${label} скопирован`,
|
||||||
message: "Секреты не сохраняются в TASKER_AGENT.md; token нужно хранить отдельно в локальном Codex.",
|
message: "Секрет не хранится в Ops Agent.md. Token нужно сохранить в локальном Codex отдельно.",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownload = (value: string, fileName: string) => {
|
||||||
|
const blob = new Blob([value], { type: "text/markdown;charset=utf-8" });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const linkElement = document.createElement("a");
|
||||||
|
linkElement.href = objectUrl;
|
||||||
|
linkElement.download = fileName;
|
||||||
|
document.body.appendChild(linkElement);
|
||||||
|
linkElement.click();
|
||||||
|
linkElement.remove();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAvatarChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setNewAgentAvatarUrl(await readAvatarDataUrl(file));
|
||||||
|
} catch (error: any) {
|
||||||
|
showAvatarError(error?.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentAvatarChange = async (agentId: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatarUrl = await readAvatarDataUrl(file);
|
||||||
|
setUpdatingAgentIds((current) => ({ ...current, [agentId]: true }));
|
||||||
|
const response = await codexAgentService.updateAgent(workspaceSlug, agentId, { avatar_url: avatarUrl });
|
||||||
|
setCreatedSetupCards((currentCards) =>
|
||||||
|
currentCards.map((card) => (card.agent.id === agentId ? { ...card, agent: response.agent } : card))
|
||||||
|
);
|
||||||
|
await mutateCodexAgents();
|
||||||
|
await mutateSetupCards();
|
||||||
|
} catch (error: any) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Не удалось обновить аватар агента",
|
||||||
|
message: error?.message ?? error?.error ?? "Проверьте формат и размер изображения.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdatingAgentIds((current) => ({ ...current, [agentId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateAgent = async () => {
|
const handleCreateAgent = async () => {
|
||||||
const displayName = newAgentName.trim();
|
const displayName = newAgentName.trim();
|
||||||
if (!displayName || !effectiveSelectedProjectId) return;
|
if (!displayName || !effectiveSelectedProjectId) return;
|
||||||
|
|
||||||
setIsCreatingAgent(true);
|
setIsCreatingAgent(true);
|
||||||
try {
|
try {
|
||||||
const createResponse = await codexAgentService.createAgent(workspaceSlug, { display_name: displayName });
|
const createResponse = await codexAgentService.createAgent(workspaceSlug, {
|
||||||
|
display_name: displayName,
|
||||||
|
avatar_url: newAgentAvatarUrl,
|
||||||
|
});
|
||||||
await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
|
await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
|
||||||
project_id: effectiveSelectedProjectId,
|
project_id: effectiveSelectedProjectId,
|
||||||
scopes: TASK_AUTHOR_SCOPES,
|
scopes: TASK_AUTHOR_SCOPES,
|
||||||
|
|
@ -99,27 +200,94 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
|
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
|
||||||
name: `${displayName} local token`,
|
name: `${displayName} local token`,
|
||||||
});
|
});
|
||||||
setTokenPacket(tokenResponse);
|
setRevealedTokens((currentTokens) => ({
|
||||||
|
...currentTokens,
|
||||||
|
[tokenResponse.token_record.id]: tokenResponse.token,
|
||||||
|
}));
|
||||||
|
setCreatedSetupCards((currentCards) =>
|
||||||
|
upsertSetupCardToken(currentCards, createResponse.agent, tokenResponse.token_record, tokenResponse.setup)
|
||||||
|
);
|
||||||
await mutateCodexAgents();
|
await mutateCodexAgents();
|
||||||
|
await mutateSetupCards();
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Codex agent создан",
|
title: "Codex agent создан",
|
||||||
message: "Token показан один раз. Скопируйте его в локальную конфигурацию Codex.",
|
message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Не удалось создать Codex agent",
|
title: "Не удалось создать Codex agent",
|
||||||
message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный проект.",
|
message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный project.",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingAgent(false);
|
setIsCreatingAgent(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateToken = async (agent: TCodexAgent) => {
|
||||||
|
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
|
||||||
|
try {
|
||||||
|
const tokenResponse = await codexAgentService.createToken(workspaceSlug, agent.id, {
|
||||||
|
name: `${agent.display_name} local token`,
|
||||||
|
});
|
||||||
|
setRevealedTokens((currentTokens) => ({
|
||||||
|
...currentTokens,
|
||||||
|
[tokenResponse.token_record.id]: tokenResponse.token,
|
||||||
|
}));
|
||||||
|
setCreatedSetupCards((currentCards) =>
|
||||||
|
upsertSetupCardToken(currentCards, agent, tokenResponse.token_record, tokenResponse.setup)
|
||||||
|
);
|
||||||
|
await mutateSetupCards();
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Новый token выпущен",
|
||||||
|
message: "Скопируйте token сейчас. После перезахода backend вернет только masked suffix.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Не удалось выпустить token",
|
||||||
|
message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAgentName = async (agent: TCodexAgent) => {
|
||||||
|
const displayName = getAgentDraftName(agentDraftNames, agent).trim();
|
||||||
|
if (!displayName) return;
|
||||||
|
|
||||||
|
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true }));
|
||||||
|
try {
|
||||||
|
const response = await codexAgentService.updateAgent(workspaceSlug, agent.id, { display_name: displayName });
|
||||||
|
setAgentDraftNames((currentDrafts) => {
|
||||||
|
const nextDrafts = { ...currentDrafts };
|
||||||
|
delete nextDrafts[agent.id];
|
||||||
|
return nextDrafts;
|
||||||
|
});
|
||||||
|
setCreatedSetupCards((currentCards) =>
|
||||||
|
currentCards.map((card) => (card.agent.id === agent.id ? { ...card, agent: response.agent } : card))
|
||||||
|
);
|
||||||
|
await mutateCodexAgents();
|
||||||
|
await mutateSetupCards();
|
||||||
|
} catch (error: any) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Не удалось сохранить агента",
|
||||||
|
message: error?.message ?? error?.error ?? "Проверьте имя агента и Gateway.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRevokeAgent = async (agentId: string) => {
|
const handleRevokeAgent = async (agentId: string) => {
|
||||||
await codexAgentService.revokeAgent(workspaceSlug, agentId);
|
await codexAgentService.revokeAgent(workspaceSlug, agentId);
|
||||||
|
setCreatedSetupCards((currentCards) => currentCards.filter((card) => card.agent.id !== agentId));
|
||||||
await mutateCodexAgents();
|
await mutateCodexAgents();
|
||||||
|
await mutateSetupCards();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -135,21 +303,21 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
<div className="nodedc-settings-card text-sm px-5 py-5 text-secondary">Загрузка статуса модуля...</div>
|
<div className="nodedc-settings-card text-sm px-5 py-5 text-secondary">Загрузка статуса модуля...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className="nodedc-settings-card overflow-hidden">
|
<section className="nodedc-settings-card px-5 py-5">
|
||||||
<div className="flex flex-col gap-4 px-5 py-5 md:flex-row md:items-start md:justify-between">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 text-16 font-semibold text-primary">
|
<div className="flex min-w-0 items-center gap-2 text-16 font-semibold text-primary">
|
||||||
<Bot className="size-5 text-tertiary" />
|
<Bot className="size-5 shrink-0 text-tertiary" />
|
||||||
<span>Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}</span>
|
<span className="min-w-0 truncate">Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 max-w-3xl text-13 leading-5 text-secondary">
|
<p className="mt-2 max-w-[56rem] 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-settings-chip inline-flex h-11 w-fit items-center gap-2 text-13 font-medium text-primary">
|
||||||
<span className="grid size-5 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]">
|
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]">
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span>{isCodexAgentEntitled ? "Доступ выдан" : "Доступ не выдан"}</span>
|
<span>{isCodexAgentEntitled ? "Доступ выдан" : "Доступ не выдан"}</span>
|
||||||
|
|
@ -176,37 +344,58 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isCodexAgentEntitled && (
|
{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">
|
<section className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-15 font-semibold text-primary">Агенты workspace</div>
|
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
|
||||||
<p className="mt-1 max-w-3xl text-13 leading-5 text-secondary">
|
<p className="max-w-3xl text-12 leading-5 text-tertiary">
|
||||||
Tasker вызывает Agent Gateway только через backend proxy. Frontend не получает сервисный Gateway
|
Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.
|
||||||
token.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)_auto]">
|
|
||||||
<input
|
<div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,1fr)_auto] xl:items-end">
|
||||||
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"
|
<div className="grid gap-2.5">
|
||||||
value={newAgentName}
|
<span className="text-body-sm-medium text-tertiary">Аватар</span>
|
||||||
onChange={(event) => setNewAgentName(event.target.value)}
|
<input
|
||||||
placeholder="Имя агента"
|
ref={createAvatarInputRef}
|
||||||
/>
|
type="file"
|
||||||
<select
|
accept={AGENT_AVATAR_ACCEPT}
|
||||||
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"
|
className="hidden"
|
||||||
value={effectiveSelectedProjectId}
|
onChange={(event) => void handleCreateAvatarChange(event)}
|
||||||
onChange={(event) => setSelectedProjectId(event.target.value)}
|
/>
|
||||||
>
|
<AgentAvatarButton
|
||||||
{projectOptions.map((project) => (
|
avatarUrl={newAgentAvatarUrl}
|
||||||
<option key={project.id} value={project.id}>
|
name={newAgentName}
|
||||||
{project.name}
|
onClick={() => createAvatarInputRef.current?.click()}
|
||||||
</option>
|
/>
|
||||||
))}
|
</div>
|
||||||
</select>
|
<label className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<span className="text-body-sm-medium text-tertiary">Задайте имя</span>
|
||||||
|
<input
|
||||||
|
className="nodedc-settings-input h-12 w-full px-4 text-13"
|
||||||
|
value={newAgentName}
|
||||||
|
onChange={(event) => setNewAgentName(event.target.value)}
|
||||||
|
placeholder="Имя агента"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<span className="text-body-sm-medium text-tertiary">Выберите project</span>
|
||||||
|
<select
|
||||||
|
className="nodedc-settings-select h-12 w-full px-4 text-13"
|
||||||
|
value={effectiveSelectedProjectId}
|
||||||
|
onChange={(event) => setSelectedProjectId(event.target.value)}
|
||||||
|
>
|
||||||
|
{projectOptions.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="nodedc-settings-save-button min-w-[11rem]"
|
className="nodedc-settings-save-button h-12 min-w-[11rem] self-end px-5"
|
||||||
disabled={!newAgentName.trim() || !effectiveSelectedProjectId}
|
disabled={!newAgentName.trim() || !effectiveSelectedProjectId}
|
||||||
loading={isCreatingAgent}
|
loading={isCreatingAgent}
|
||||||
onClick={handleCreateAgent}
|
onClick={handleCreateAgent}
|
||||||
|
|
@ -214,104 +403,187 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
Создать агента
|
Создать агента
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{codexAgentsError && (
|
{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">
|
<div className="bg-red-500/10 text-red-300 rounded-xl px-4 py-3 text-13">
|
||||||
Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и
|
Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и
|
||||||
`PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
|
`PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3">
|
{areAgentsLoading ? (
|
||||||
{areAgentsLoading ? (
|
<div className="nodedc-settings-card px-5 py-5 text-13 text-secondary">Загрузка агентов...</div>
|
||||||
<div className="text-13 text-secondary">Загрузка агентов...</div>
|
) : activeAgents.length > 0 ? (
|
||||||
) : activeAgents.length > 0 ? (
|
activeAgents.map((agent) => {
|
||||||
activeAgents.map((agent) => (
|
const draftName = getAgentDraftName(agentDraftNames, agent);
|
||||||
<div
|
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
|
||||||
key={agent.id}
|
const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
|
||||||
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"
|
const isAgentDirty = draftName.trim() !== agent.display_name;
|
||||||
>
|
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
|
||||||
<div className="min-w-0">
|
const agentTokens = setupCard?.tokens ?? [];
|
||||||
<div className="text-14 font-semibold text-primary">{agent.display_name}</div>
|
const setup = setupCard?.setup;
|
||||||
<div className="mt-1 text-12 text-secondary">
|
|
||||||
status: {agent.status} · created: {new Date(agent.created_at).toLocaleString()}
|
return (
|
||||||
|
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,0.8fr)_auto] xl:items-end">
|
||||||
|
<div className="grid gap-2.5">
|
||||||
|
<span className="text-body-sm-medium text-tertiary">Аватар</span>
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
agentAvatarInputRefs.current[agent.id] = element;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept={AGENT_AVATAR_ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => void handleAgentAvatarChange(agent.id, event)}
|
||||||
|
/>
|
||||||
|
<AgentAvatarButton
|
||||||
|
avatarUrl={agent.avatar_url}
|
||||||
|
disabled={isUpdatingAgent}
|
||||||
|
name={draftName}
|
||||||
|
onClick={() => agentAvatarInputRefs.current[agent.id]?.click()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<span className="text-body-sm-medium text-tertiary">Имя агента</span>
|
||||||
|
<input
|
||||||
|
className="nodedc-settings-input h-12 w-full px-4 text-13"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAgentDraftNames((currentDrafts) => ({
|
||||||
|
...currentDrafts,
|
||||||
|
[agent.id]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Имя агента"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<span className="text-body-sm-medium text-tertiary">Состояние</span>
|
||||||
|
<div className="nodedc-settings-input flex h-12 items-center px-4 text-13 text-secondary">
|
||||||
|
active · {new Date(agent.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 self-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip h-12"
|
||||||
|
disabled={!isAgentDirty}
|
||||||
|
loading={isUpdatingAgent}
|
||||||
|
onClick={() => void handleSaveAgentName(agent)}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip h-12"
|
||||||
|
loading={isCreatingToken}
|
||||||
|
onClick={() => void handleCreateToken(agent)}
|
||||||
|
>
|
||||||
|
Новый token
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip h-12"
|
||||||
|
onClick={() => void handleRevokeAgent(agent.id)}
|
||||||
|
>
|
||||||
|
Отозвать
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{areSetupCardsLoading && agentTokens.length === 0 ? (
|
||||||
<section className="nodedc-settings-card px-5 py-5">
|
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
Загрузка token и Ops Agent.md...
|
||||||
<div>
|
</div>
|
||||||
<div className="text-15 font-semibold text-primary">Одноразовый token и setup packet</div>
|
) : agentTokens.length > 0 ? (
|
||||||
<p className="mt-1 text-13 text-secondary">
|
<div className="grid gap-4">
|
||||||
Token больше не будет доступен после закрытия этого блока. Markdown-инструкция не содержит секрет.
|
{agentTokens.map((token) => {
|
||||||
</p>
|
const revealedToken = revealedTokens[token.id];
|
||||||
|
const tokenValue = revealedToken ?? maskToken(token);
|
||||||
|
const isTokenRevealed = Boolean(revealedToken);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={token.id} className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)]">
|
||||||
|
<div className="nodedc-settings-field p-4">
|
||||||
|
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
|
||||||
|
Agent token
|
||||||
|
</div>
|
||||||
|
<code className="nodedc-settings-input block min-h-12 px-3 py-3 text-12 break-all text-primary">
|
||||||
|
{tokenValue}
|
||||||
|
</code>
|
||||||
|
{isTokenRevealed && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip"
|
||||||
|
onClick={() => void handleCopy(revealedToken, "Токен")}
|
||||||
|
>
|
||||||
|
Скопировать токен
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-settings-field p-4">
|
||||||
|
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
|
||||||
|
Ops Agent.md
|
||||||
|
</div>
|
||||||
|
{setup?.agents_md ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12"
|
||||||
|
value={setup.agents_md}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip"
|
||||||
|
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
|
||||||
|
>
|
||||||
|
Скопировать Ops Agent.md
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="nodedc-settings-chip"
|
||||||
|
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
|
||||||
|
Setup packet пока недоступен. Проверьте Gateway и grants агента.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
|
||||||
|
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
|
||||||
|
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -319,6 +591,137 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
|
||||||
|
return agentDraftNames[agent.id] ?? agent.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const words = name.trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
||||||
|
const initials = words.map((word) => word[0]?.toUpperCase()).join("");
|
||||||
|
return initials || "A";
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskToken(token: TCodexAgentToken): string {
|
||||||
|
return `${"×".repeat(16)}${token.token_suffix ?? token.id.slice(-8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarSrc(avatarUrl?: string | null): string | null {
|
||||||
|
if (!avatarUrl) return null;
|
||||||
|
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
|
||||||
|
return getFileURL(avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
|
||||||
|
const sizeClassName = props.size === "lg" ? "size-12 text-16" : "size-10 text-13";
|
||||||
|
const commonClassName = `${sizeClassName} shrink-0 overflow-hidden rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]`;
|
||||||
|
const avatarSrc = getAvatarSrc(props.avatarUrl);
|
||||||
|
|
||||||
|
if (avatarSrc) {
|
||||||
|
return <img src={avatarSrc} alt={props.name} className={`${commonClassName} object-cover`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={`${commonClassName} grid place-items-center font-semibold`}>{getInitials(props.name)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentAvatarButton(props: {
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
name: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative size-12 rounded-full transition outline-none hover:opacity-90 disabled:cursor-wait disabled:opacity-70"
|
||||||
|
disabled={props.disabled}
|
||||||
|
onClick={props.onClick}
|
||||||
|
title="Изменить аватар"
|
||||||
|
>
|
||||||
|
<AgentAvatar avatarUrl={props.avatarUrl} name={props.name} size="lg" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgentSetupCard[]): TAgentSetupCard[] {
|
||||||
|
const cardsByAgentId = new Map<string, TAgentSetupCard>();
|
||||||
|
|
||||||
|
for (const card of persistedCards) {
|
||||||
|
cardsByAgentId.set(card.agent.id, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const card of createdCards) {
|
||||||
|
const persistedCard = cardsByAgentId.get(card.agent.id);
|
||||||
|
if (!persistedCard) {
|
||||||
|
cardsByAgentId.set(card.agent.id, card);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsByAgentId.set(card.agent.id, {
|
||||||
|
agent: persistedCard.agent,
|
||||||
|
setup: persistedCard.setup ?? card.setup,
|
||||||
|
tokens: mergeTokens(persistedCard.tokens, card.tokens),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(cardsByAgentId.values()).filter((card) => card.tokens.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
|
||||||
|
const tokensById = new Map<string, TCodexAgentToken>();
|
||||||
|
for (const token of primaryTokens) tokensById.set(token.id, token);
|
||||||
|
for (const token of secondaryTokens) tokensById.set(token.id, tokensById.get(token.id) ?? token);
|
||||||
|
return Array.from(tokensById.values()).sort(
|
||||||
|
(leftToken, rightToken) => new Date(rightToken.created_at).getTime() - new Date(leftToken.created_at).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertSetupCardToken(
|
||||||
|
cards: TAgentSetupCard[],
|
||||||
|
agent: TCodexAgent,
|
||||||
|
token: TCodexAgentToken,
|
||||||
|
setup?: TCodexAgentSetupPacket
|
||||||
|
): TAgentSetupCard[] {
|
||||||
|
const existingCard = cards.find((card) => card.agent.id === agent.id);
|
||||||
|
if (!existingCard) {
|
||||||
|
return [{ agent, setup, tokens: [token] }, ...cards];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards.map((card) =>
|
||||||
|
card.agent.id === agent.id
|
||||||
|
? {
|
||||||
|
agent,
|
||||||
|
setup: setup ?? card.setup,
|
||||||
|
tokens: mergeTokens([token], card.tokens),
|
||||||
|
}
|
||||||
|
: card
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAvatarDataUrl(file: File): Promise<string> {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_AGENT_AVATAR_BYTES) {
|
||||||
|
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара."));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAvatarError(message?: string) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Не удалось загрузить аватар",
|
||||||
|
message: message ?? "Проверьте формат и размер изображения.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type TCapabilityCardProps = {
|
type TCapabilityCardProps = {
|
||||||
description: string;
|
description: string;
|
||||||
icon: typeof ShieldCheck;
|
icon: typeof ShieldCheck;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export type TCodexAgentToken = {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: TCodexAgentTokenStatus;
|
status: TCodexAgentTokenStatus;
|
||||||
|
token_suffix: string | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
last_used_at: string | null;
|
last_used_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -66,6 +67,16 @@ export type TCodexAgentCreateTokenResponse = {
|
||||||
token_record: TCodexAgentToken;
|
token_record: TCodexAgentToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentTokenListResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
tokens: TCodexAgentToken[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCodexAgentSetupResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
setup?: TCodexAgentSetupPacket;
|
||||||
|
};
|
||||||
|
|
||||||
export class WorkspaceCodexAgentService extends APIService {
|
export class WorkspaceCodexAgentService extends APIService {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(API_BASE_URL);
|
super(API_BASE_URL);
|
||||||
|
|
@ -90,6 +101,18 @@ export class WorkspaceCodexAgentService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAgent(
|
||||||
|
workspaceSlug: string,
|
||||||
|
agentId: string,
|
||||||
|
data: { display_name?: string; avatar_url?: string | null }
|
||||||
|
): Promise<{ ok: boolean; agent: TCodexAgent }> {
|
||||||
|
return this.patch(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async upsertGrant(
|
async upsertGrant(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|
@ -118,6 +141,22 @@ export class WorkspaceCodexAgentService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listTokens(workspaceSlug: string, agentId: string): Promise<TCodexAgentTokenListResponse> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSetup(workspaceSlug: string, agentId: string): Promise<TCodexAgentSetupResponse> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/setup/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async revokeAgent(workspaceSlug: string, agentId: string): Promise<{ ok: boolean; agent: TCodexAgent }> {
|
async revokeAgent(workspaceSlug: string, agentId: string): Promise<{ ok: boolean; agent: TCodexAgent }> {
|
||||||
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/revoke/`)
|
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/revoke/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export const WORKSPACE_SETTINGS: Record<TWorkspaceSettingsTabs, TWorkspaceSettin
|
||||||
key: "codex-agent-api",
|
key: "codex-agent-api",
|
||||||
i18n_label: "workspace_settings.settings.codex_agent_api.title",
|
i18n_label: "workspace_settings.settings.codex_agent_api.title",
|
||||||
href: `/settings/codex-agent-api`,
|
href: `/settings/codex-agent-api`,
|
||||||
access: [EUserWorkspaceRoles.ADMIN],
|
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/codex-agent-api/`,
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/codex-agent-api/`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue