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="Имя агента"
- />
-
setSelectedProjectId(event.target.value)}
- >
- {projectOptions.map((project) => (
-
- {project.name}
-
- ))}
-
+
+
+
+
Аватар
+
void handleCreateAvatarChange(event)}
+ />
+
createAvatarInputRef.current?.click()}
+ />
+
+
+ Задайте имя
+ setNewAgentName(event.target.value)}
+ placeholder="Имя агента"
+ />
+
+
+ Выберите project
+ setSelectedProjectId(event.target.value)}
+ >
+ {projectOptions.map((project) => (
+
+ {project.name}
+
+ ))}
+
+
-
+
{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()}
+ />
+
+
+ Имя агента
+
+ setAgentDraftNames((currentDrafts) => ({
+ ...currentDrafts,
+ [agent.id]: event.target.value,
+ }))
+ }
+ placeholder="Имя агента"
+ />
+
+
+
Состояние
+
+ active · {new Date(agent.created_at).toLocaleString()}
+
+
+
+ void handleSaveAgentName(agent)}
+ >
+ Сохранить
+
+ void handleCreateToken(agent)}
+ >
+ Новый token
+
+ void handleRevokeAgent(agent.id)}
+ >
+ Отозвать
+
- void handleRevokeAgent(agent.id)}
- >
- Отозвать
-
-
- ))
- ) : (
-
- Агентов пока нет. Создайте агента, выберите проект и сразу получите 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 && (
+
+ void handleCopy(revealedToken, "Токен")}
+ >
+ Скопировать токен
+
+
+ )}
+
+
+
+
+ Ops Agent.md
+
+ {setup?.agents_md ? (
+ <>
+
+
+ void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
+ >
+ Скопировать Ops Agent.md
+
+ handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
+ >
+ Скачать
+
+
+ >
+ ) : (
+
+ Setup packet пока недоступен. Проверьте Gateway и grants агента.
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
+
+ )}
+
+ );
+ })
+ ) : (
+
+ Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
-
setTokenPacket(null)}
- >
- Скрыть
-
-
-
-
-
Agent token
-
- {tokenPacket.token}
-
-
void handleCopy(tokenPacket.token, "Agent token")}
- >
- Copy token
-
-
- {tokenPacket.setup?.agents_md && (
-
-
- TASKER_AGENT.md
-
-
-
void handleCopy(tokenPacket.setup?.agents_md ?? "", "TASKER_AGENT.md")}
- >
- Copy TASKER_AGENT.md
-
-
- )}
-
-
+ )}
+ >
)}
>
)}
@@ -319,6 +591,137 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
);
});
+function getAgentDraftName(agentDraftNames: Record
, 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 ;
+ }
+
+ return {getInitials(props.name)}
;
+}
+
+function AgentAvatarButton(props: {
+ avatarUrl?: string | null;
+ disabled?: boolean;
+ name: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgentSetupCard[]): TAgentSetupCard[] {
+ const cardsByAgentId = new Map();
+
+ 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();
+ 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 {
+ 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 = {
description: string;
icon: typeof ShieldCheck;
diff --git a/plane-src/apps/web/core/services/workspace-codex-agent.service.ts b/plane-src/apps/web/core/services/workspace-codex-agent.service.ts
index 735d3e6..e9a298d 100644
--- a/plane-src/apps/web/core/services/workspace-codex-agent.service.ts
+++ b/plane-src/apps/web/core/services/workspace-codex-agent.service.ts
@@ -39,6 +39,7 @@ export type TCodexAgentToken = {
agent_id: string;
name: string;
status: TCodexAgentTokenStatus;
+ token_suffix: string | null;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
@@ -66,6 +67,16 @@ export type TCodexAgentCreateTokenResponse = {
token_record: TCodexAgentToken;
};
+export type TCodexAgentTokenListResponse = {
+ ok: boolean;
+ tokens: TCodexAgentToken[];
+};
+
+export type TCodexAgentSetupResponse = {
+ ok: boolean;
+ setup?: TCodexAgentSetupPacket;
+};
+
export class WorkspaceCodexAgentService extends APIService {
constructor() {
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(
workspaceSlug: string,
agentId: string,
@@ -118,6 +141,22 @@ export class WorkspaceCodexAgentService extends APIService {
});
}
+ async listTokens(workspaceSlug: string, agentId: string): Promise {
+ 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 {
+ 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 }> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/revoke/`)
.then((response) => response?.data)
diff --git a/plane-src/packages/constants/src/settings/workspace.ts b/plane-src/packages/constants/src/settings/workspace.ts
index 03a4ebe..7ce7a37 100644
--- a/plane-src/packages/constants/src/settings/workspace.ts
+++ b/plane-src/packages/constants/src/settings/workspace.ts
@@ -74,7 +74,7 @@ export const WORKSPACE_SETTINGS: Record pathname === `${baseUrl}/settings/codex-agent-api/`,
},
};