diff --git a/plane-src/apps/api/plane/app/views/codex_agents.py b/plane-src/apps/api/plane/app/views/codex_agents.py index 0ddc44e..8f66603 100644 --- a/plane-src/apps/api/plane/app/views/codex_agents.py +++ b/plane-src/apps/api/plane/app/views/codex_agents.py @@ -12,7 +12,7 @@ from rest_framework.response import Response 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 +from plane.db.models import Project, ProjectMember, Workspace, WorkspaceMember 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 workspace_admin: + return Response( + { + "ok": False, + "error": "project_required", + "message": "Workspace members must select a concrete project for Codex Agent grants.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) return None try: - 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: - exists = False + project = None - if not exists: + if project is None: return Response( { "ok": False, @@ -98,6 +117,25 @@ def validate_project_in_workspace(workspace, project_id): }, 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 @@ -147,7 +185,7 @@ class CodexAgentEntitledEndpoint(BaseAPIView): 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): entitlement_error = self.require_entitlement(request, slug) 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") - @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: @@ -177,7 +215,7 @@ class CodexAgentListEndpoint(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): entitlement_error = self.require_entitlement(request, slug) 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)}") - @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): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: @@ -200,7 +238,7 @@ class CodexAgentDetailEndpoint(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): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: @@ -214,7 +252,7 @@ class CodexAgentRevokeEndpoint(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): entitlement_error = self.require_entitlement(request, slug) 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") - @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): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: @@ -233,7 +271,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint): return workspace_error 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: return project_error @@ -247,7 +285,7 @@ class CodexAgentGrantListEndpoint(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): entitlement_error = self.require_entitlement(request, slug) 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") - @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): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: @@ -269,7 +307,7 @@ class CodexAgentTokenListEndpoint(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): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: @@ -283,7 +321,7 @@ class CodexAgentTokenRevokeEndpoint(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): entitlement_error = self.require_entitlement(request, slug) if entitlement_error is not None: diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py index e721b6c..0f6c011 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py @@ -1,5 +1,6 @@ from html import escape +from django.core.exceptions import ValidationError from django.db import transaction from django.http import JsonResponse 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.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.db.models import ( Issue, @@ -244,6 +246,33 @@ def validate_internal_request(request): 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): text = value.strip() if isinstance(value, str) else "" return f"

{escape(text)}

" if text else "

" @@ -319,6 +348,9 @@ class NodeDCAgentProjectResolveEndpoint(View): project_id = grant.get("project_id") if not isinstance(workspace_slug, str) or not workspace_slug: 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: project_filters.append((workspace_slug, project_id)) else: @@ -358,6 +390,10 @@ class NodeDCAgentProjectContextEndpoint(View): if project is None: 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") labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order") members = ( @@ -392,6 +428,10 @@ class NodeDCAgentIssueListEndpoint(View): if project is None: 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 = ( Issue.issue_objects.filter(project=project) .select_related("workspace", "project", "state") @@ -416,6 +456,10 @@ class NodeDCAgentIssueListEndpoint(View): if project is None: 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") if not isinstance(title, str) or not title.strip(): return validation_error("title_required") @@ -466,6 +510,10 @@ class NodeDCAgentIssueUpdateEndpoint(View): if issue is None: 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 = ( merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks")) if "structured_blocks" in payload @@ -519,6 +567,10 @@ class NodeDCAgentIssueMoveEndpoint(View): if issue is None: 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() if state is None: return validation_error("state_not_found", status=404) @@ -555,6 +607,10 @@ class NodeDCAgentIssueCommentEndpoint(View): if issue is None: 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") if not isinstance(body, str) or not body.strip(): return validation_error("body_required") @@ -592,6 +648,10 @@ class NodeDCAgentIssueLabelsEndpoint(View): if issue is None: 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") if not isinstance(label_ids, list): return validation_error("label_ids_required") @@ -625,6 +685,10 @@ class NodeDCAgentIssueAssigneesEndpoint(View): if issue is None: 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") if not isinstance(member_ids, list): return validation_error("member_ids_required") diff --git a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx index 7f67cff..31513ae 100644 --- a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx @@ -4,12 +4,13 @@ * 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 { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react"; import useSWR from "swr"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { getFileURL } from "@plane/utils"; // components import { SettingsHeading } from "@/components/settings/heading"; // hooks @@ -18,7 +19,9 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; import { ProjectService } from "@/services/project/project.service"; import { WorkspaceCodexAgentService, - type TCodexAgentCreateTokenResponse, + type TCodexAgent, + type TCodexAgentSetupPacket, + type TCodexAgentToken, } from "@/services/workspace-codex-agent.service"; import { WorkspaceService } from "@/services/workspace.service"; @@ -36,6 +39,10 @@ const TASK_AUTHOR_SCOPES = [ "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 projectService = new ProjectService(); const workspaceService = new WorkspaceService(); @@ -45,12 +52,25 @@ type TProps = { workspaceSlug: string; }; +type TAgentSetupCard = { + agent: TCodexAgent; + setup?: TCodexAgentSetupPacket; + tokens: TCodexAgentToken[]; +}; + export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) { const { showHeading = true, workspaceSlug } = props; + const createAvatarInputRef = useRef(null); + const agentAvatarInputRefs = useRef>({}); const [isCreatingAgent, setIsCreatingAgent] = useState(false); const [newAgentName, setNewAgentName] = useState("Local Codex"); + const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(""); - const [tokenPacket, setTokenPacket] = useState(null); + const [agentDraftNames, setAgentDraftNames] = useState>({}); + const [createdSetupCards, setCreatedSetupCards] = useState([]); + const [revealedTokens, setRevealedTokens] = useState>({}); + const [updatingAgentIds, setUpdatingAgentIds] = useState>({}); + const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState>({}); const { currentWorkspace } = useWorkspace(); const { data: nodedcWorkspacePolicy, isLoading } = useSWR( 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] ); + 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 effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || ""; + const setupCards = useMemo( + () => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards), + [createdSetupCards, persistedSetupCards] + ); 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.", + 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) => { + 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) => { + 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 displayName = newAgentName.trim(); if (!displayName || !effectiveSelectedProjectId) return; setIsCreatingAgent(true); 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, { project_id: effectiveSelectedProjectId, scopes: TASK_AUTHOR_SCOPES, @@ -99,27 +200,94 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, { 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 mutateSetupCards(); setToast({ type: TOAST_TYPE.SUCCESS, title: "Codex agent создан", - message: "Token показан один раз. Скопируйте его в локальную конфигурацию Codex.", + message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.", }); } catch (error: any) { setToast({ type: TOAST_TYPE.ERROR, title: "Не удалось создать Codex agent", - message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный проект.", + message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный project.", }); } finally { 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) => { await codexAgentService.revokeAgent(workspaceSlug, agentId); + setCreatedSetupCards((currentCards) => currentCards.filter((card) => card.agent.id !== agentId)); await mutateCodexAgents(); + await mutateSetupCards(); }; return ( @@ -135,21 +303,21 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
Загрузка статуса модуля...
) : ( <> -
-
+
+
-
- - Agent Gateway для {currentWorkspace?.name ?? workspaceSlug} +
+ + Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}
-

+

Доступ к модулю приходит из Launcher entitlement Operational Core → Codex Agent API. Если entitlement снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный модуль.

-
- +
+ {isCodexAgentEntitled ? "Доступ выдан" : "Доступ не выдан"} @@ -176,37 +344,58 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
{isCodexAgentEntitled && ( -
-
-
-
Агенты workspace
-

- Tasker вызывает Agent Gateway только через backend proxy. Frontend не получает сервисный Gateway - token. + <> +

+
+
Создать агента workspace
+

+ Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.

-
- setNewAgentName(event.target.value)} - placeholder="Имя агента" - /> - + +
+
+ Аватар + void handleCreateAvatarChange(event)} + /> + createAvatarInputRef.current?.click()} + /> +
+ +
-
+
{codexAgentsError && ( -
+
Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и `PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
)} -
- {areAgentsLoading ? ( -
Загрузка агентов...
- ) : activeAgents.length > 0 ? ( - activeAgents.map((agent) => ( -
-
-
{agent.display_name}
-
- status: {agent.status} · created: {new Date(agent.created_at).toLocaleString()} + {areAgentsLoading ? ( +
Загрузка агентов...
+ ) : activeAgents.length > 0 ? ( + activeAgents.map((agent) => { + const draftName = getAgentDraftName(agentDraftNames, agent); + const isUpdatingAgent = updatingAgentIds[agent.id] === true; + const isCreatingToken = creatingTokenAgentIds[agent.id] === true; + const isAgentDirty = draftName.trim() !== agent.display_name; + const setupCard = setupCards.find((card) => card.agent.id === agent.id); + const agentTokens = setupCard?.tokens ?? []; + const setup = setupCard?.setup; + + return ( +
+
+
+ Аватар + { + agentAvatarInputRefs.current[agent.id] = element; + }} + type="file" + accept={AGENT_AVATAR_ACCEPT} + className="hidden" + onChange={(event) => void handleAgentAvatarChange(agent.id, event)} + /> + agentAvatarInputRefs.current[agent.id]?.click()} + /> +
+ +
+ Состояние +
+ active · {new Date(agent.created_at).toLocaleString()} +
+
+
+ + +
- -
- )) - ) : ( -
- Агентов пока нет. Создайте агента, выберите проект и сразу получите token + TASKER_AGENT.md. -
- )} -
-
- )} - {tokenPacket && ( -
-
-
-
Одноразовый token и setup packet
-

- Token больше не будет доступен после закрытия этого блока. Markdown-инструкция не содержит секрет. -

+ {areSetupCardsLoading && agentTokens.length === 0 ? ( +
+ Загрузка token и Ops Agent.md... +
+ ) : agentTokens.length > 0 ? ( +
+ {agentTokens.map((token) => { + const revealedToken = revealedTokens[token.id]; + const tokenValue = revealedToken ?? maskToken(token); + const isTokenRevealed = Boolean(revealedToken); + + return ( +
+
+
+ Agent token +
+ + {tokenValue} + + {isTokenRevealed && ( +
+ +
+ )} +
+ +
+
+ Ops Agent.md +
+ {setup?.agents_md ? ( + <> +