SEC - TASKER: enforce workspace-scoped agent access

This commit is contained in:
DCCONSTRUCTIONS 2026-05-15 00:47:59 +03:00
parent 65ee15b86c
commit b0a682b63b
5 changed files with 691 additions and 147 deletions

View File

@ -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:

View File

@ -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")

View File

@ -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,24 +344,44 @@ 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]">
<div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,1fr)_auto] xl:items-end">
<div className="grid gap-2.5">
<span className="text-body-sm-medium text-tertiary">Аватар</span>
<input <input
className="nodedc-settings-input border-custom-border-200 bg-custom-background-100 h-10 min-w-0 rounded-lg border px-3 text-13 text-primary outline-none" ref={createAvatarInputRef}
type="file"
accept={AGENT_AVATAR_ACCEPT}
className="hidden"
onChange={(event) => void handleCreateAvatarChange(event)}
/>
<AgentAvatarButton
avatarUrl={newAgentAvatarUrl}
name={newAgentName}
onClick={() => createAvatarInputRef.current?.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={newAgentName} value={newAgentName}
onChange={(event) => setNewAgentName(event.target.value)} onChange={(event) => setNewAgentName(event.target.value)}
placeholder="Имя агента" placeholder="Имя агента"
/> />
</label>
<label className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Выберите project</span>
<select <select
className="nodedc-settings-input border-custom-border-200 bg-custom-background-100 h-10 min-w-0 rounded-lg border px-3 text-13 text-primary outline-none" className="nodedc-settings-select h-12 w-full px-4 text-13"
value={effectiveSelectedProjectId} value={effectiveSelectedProjectId}
onChange={(event) => setSelectedProjectId(event.target.value)} onChange={(event) => setSelectedProjectId(event.target.value)}
> >
@ -203,10 +391,11 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</option> </option>
))} ))}
</select> </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="text-13 text-secondary">Загрузка агентов...</div> <div className="nodedc-settings-card px-5 py-5 text-13 text-secondary">Загрузка агентов...</div>
) : activeAgents.length > 0 ? ( ) : activeAgents.length > 0 ? (
activeAgents.map((agent) => ( activeAgents.map((agent) => {
<div const draftName = getAgentDraftName(agentDraftNames, agent);
key={agent.id} const isUpdatingAgent = updatingAgentIds[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 isCreatingToken = creatingTokenAgentIds[agent.id] === true;
> const isAgentDirty = draftName.trim() !== agent.display_name;
<div className="min-w-0"> const setupCard = setupCards.find((card) => card.agent.id === agent.id);
<div className="text-14 font-semibold text-primary">{agent.display_name}</div> const agentTokens = setupCard?.tokens ?? [];
<div className="mt-1 text-12 text-secondary"> const setup = setupCard?.setup;
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> </div>
<div className="flex flex-wrap gap-2 self-end">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="nodedc-settings-chip" 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)} onClick={() => void handleRevokeAgent(agent.id)}
> >
Отозвать Отозвать
</Button> </Button>
</div> </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>
)}
</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 className="text-15 font-semibold text-primary">Одноразовый token и setup packet</div>
<p className="mt-1 text-13 text-secondary">
Token больше не будет доступен после закрытия этого блока. Markdown-инструкция не содержит секрет.
</p>
</div> </div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
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 <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="nodedc-settings-chip" className="nodedc-settings-chip"
onClick={() => setTokenPacket(null)} onClick={() => void handleCopy(revealedToken, "Токен")}
> >
Скрыть Скопировать токен
</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> </Button>
</div> </div>
)} )}
</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> </section>
);
})
) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
</div>
)}
</>
)} )}
</> </>
)} )}
@ -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;

View File

@ -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)

View File

@ -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/`,
}, },
}; };