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

View File

@ -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"<p>{escape(text)}</p>" if text else "<p></p>"
@ -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")

View File

@ -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<HTMLInputElement | null>(null);
const agentAvatarInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [newAgentName, setNewAgentName] = useState("Local Codex");
const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState<string | null>(null);
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 { 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<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 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
<div className="nodedc-settings-card text-sm px-5 py-5 text-secondary">Загрузка статуса модуля...</div>
) : (
<>
<section className="nodedc-settings-card overflow-hidden">
<div className="flex flex-col gap-4 px-5 py-5 md:flex-row md:items-start md:justify-between">
<section className="nodedc-settings-card px-5 py-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
<div className="min-w-0">
<div className="flex items-center gap-2 text-16 font-semibold text-primary">
<Bot className="size-5 text-tertiary" />
<span>Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}</span>
<div className="flex min-w-0 items-center gap-2 text-16 font-semibold text-primary">
<Bot className="size-5 shrink-0 text-tertiary" />
<span className="min-w-0 truncate">Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}</span>
</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
снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный
модуль.
</p>
</div>
<div className="nodedc-external-readonly-value shrink-0">
<span className="grid size-5 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]">
<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 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" />
</span>
<span>{isCodexAgentEntitled ? "Доступ выдан" : "Доступ не выдан"}</span>
@ -176,37 +344,58 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</section>
{isCodexAgentEntitled && (
<section className="nodedc-settings-card px-5 py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<div className="text-15 font-semibold text-primary">Агенты workspace</div>
<p className="mt-1 max-w-3xl text-13 leading-5 text-secondary">
Tasker вызывает Agent Gateway только через backend proxy. Frontend не получает сервисный Gateway
token.
<>
<section className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
<div className="flex flex-col gap-1.5">
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
<p className="max-w-3xl text-12 leading-5 text-tertiary">
Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)_auto]">
<input
className="nodedc-settings-input border-custom-border-200 bg-custom-background-100 h-10 min-w-0 rounded-lg border px-3 text-13 text-primary outline-none"
value={newAgentName}
onChange={(event) => setNewAgentName(event.target.value)}
placeholder="Имя агента"
/>
<select
className="nodedc-settings-input border-custom-border-200 bg-custom-background-100 h-10 min-w-0 rounded-lg border px-3 text-13 text-primary outline-none"
value={effectiveSelectedProjectId}
onChange={(event) => setSelectedProjectId(event.target.value)}
>
{projectOptions.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<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
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}
onChange={(event) => setNewAgentName(event.target.value)}
placeholder="Имя агента"
/>
</label>
<label className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Выберите project</span>
<select
className="nodedc-settings-select h-12 w-full px-4 text-13"
value={effectiveSelectedProjectId}
onChange={(event) => setSelectedProjectId(event.target.value)}
>
{projectOptions.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<Button
variant="primary"
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}
loading={isCreatingAgent}
onClick={handleCreateAgent}
@ -214,104 +403,187 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
Создать агента
</Button>
</div>
</div>
</section>
{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` и
`PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
</div>
)}
<div className="mt-5 grid gap-3">
{areAgentsLoading ? (
<div className="text-13 text-secondary">Загрузка агентов...</div>
) : activeAgents.length > 0 ? (
activeAgents.map((agent) => (
<div
key={agent.id}
className="border-custom-border-200 bg-custom-background-90 flex flex-col gap-3 rounded-xl border px-4 py-3 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<div className="text-14 font-semibold text-primary">{agent.display_name}</div>
<div className="mt-1 text-12 text-secondary">
status: {agent.status} · created: {new Date(agent.created_at).toLocaleString()}
{areAgentsLoading ? (
<div className="nodedc-settings-card px-5 py-5 text-13 text-secondary">Загрузка агентов...</div>
) : 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 (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
<div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,0.8fr)_auto] xl:items-end">
<div className="grid gap-2.5">
<span className="text-body-sm-medium text-tertiary">Аватар</span>
<input
ref={(element) => {
agentAvatarInputRefs.current[agent.id] = element;
}}
type="file"
accept={AGENT_AVATAR_ACCEPT}
className="hidden"
onChange={(event) => void handleAgentAvatarChange(agent.id, event)}
/>
<AgentAvatarButton
avatarUrl={agent.avatar_url}
disabled={isUpdatingAgent}
name={draftName}
onClick={() => agentAvatarInputRefs.current[agent.id]?.click()}
/>
</div>
<label className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Имя агента</span>
<input
className="nodedc-settings-input h-12 w-full px-4 text-13"
value={draftName}
onChange={(event) =>
setAgentDraftNames((currentDrafts) => ({
...currentDrafts,
[agent.id]: event.target.value,
}))
}
placeholder="Имя агента"
/>
</label>
<div className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Состояние</span>
<div className="nodedc-settings-input flex h-12 items-center px-4 text-13 text-secondary">
active · {new Date(agent.created_at).toLocaleString()}
</div>
</div>
<div className="flex flex-wrap gap-2 self-end">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
disabled={!isAgentDirty}
loading={isUpdatingAgent}
onClick={() => void handleSaveAgentName(agent)}
>
Сохранить
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый token
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
onClick={() => void handleRevokeAgent(agent.id)}
>
Отозвать
</Button>
</div>
</div>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleRevokeAgent(agent.id)}
>
Отозвать
</Button>
</div>
))
) : (
<div className="border-custom-border-200 rounded-xl border border-dashed px-4 py-6 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите проект и сразу получите token + TASKER_AGENT.md.
</div>
)}
</div>
</section>
)}
{tokenPacket && (
<section className="nodedc-settings-card px-5 py-5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-15 font-semibold text-primary">Одноразовый token и setup packet</div>
<p className="mt-1 text-13 text-secondary">
Token больше не будет доступен после закрытия этого блока. Markdown-инструкция не содержит секрет.
</p>
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка token и Ops Agent.md...
</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
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(revealedToken, "Токен")}
>
Скопировать токен
</Button>
</div>
)}
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Ops Agent.md
</div>
{setup?.agents_md ? (
<>
<textarea
readOnly
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12"
value={setup.agents_md}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
>
Скопировать Ops Agent.md
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
>
Скачать
</Button>
</div>
</>
) : (
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
Setup packet пока недоступен. Проверьте Gateway и grants агента.
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
</div>
)}
</section>
);
})
) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
</div>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => setTokenPacket(null)}
>
Скрыть
</Button>
</div>
<div className="mt-4 grid gap-4">
<div className="border-custom-border-200 bg-custom-background-90 rounded-xl border p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">Agent token</div>
<code className="bg-custom-background-80 block rounded-lg px-3 py-2 text-12 break-all text-primary">
{tokenPacket.token}
</code>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip mt-3"
onClick={() => void handleCopy(tokenPacket.token, "Agent token")}
>
Copy token
</Button>
</div>
{tokenPacket.setup?.agents_md && (
<div className="border-custom-border-200 bg-custom-background-90 rounded-xl border p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
TASKER_AGENT.md
</div>
<textarea
readOnly
className="border-custom-border-200 bg-custom-background-80 font-mono h-64 w-full resize-y rounded-lg border px-3 py-2 text-12 text-primary outline-none"
value={tokenPacket.setup.agents_md}
/>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip mt-3"
onClick={() => void handleCopy(tokenPacket.setup?.agents_md ?? "", "TASKER_AGENT.md")}
>
Copy TASKER_AGENT.md
</Button>
</div>
)}
</div>
</section>
)}
</>
)}
</>
)}
@ -319,6 +591,137 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
);
});
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
return agentDraftNames[agent.id] ?? agent.display_name;
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean).slice(0, 2);
const initials = words.map((word) => word[0]?.toUpperCase()).join("");
return initials || "A";
}
function maskToken(token: TCodexAgentToken): string {
return `${"×".repeat(16)}${token.token_suffix ?? token.id.slice(-8)}`;
}
function getAvatarSrc(avatarUrl?: string | null): string | null {
if (!avatarUrl) return null;
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
return getFileURL(avatarUrl);
}
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
const sizeClassName = props.size === "lg" ? "size-12 text-16" : "size-10 text-13";
const commonClassName = `${sizeClassName} shrink-0 overflow-hidden rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]`;
const avatarSrc = getAvatarSrc(props.avatarUrl);
if (avatarSrc) {
return <img src={avatarSrc} alt={props.name} className={`${commonClassName} object-cover`} />;
}
return <div className={`${commonClassName} grid place-items-center font-semibold`}>{getInitials(props.name)}</div>;
}
function AgentAvatarButton(props: {
avatarUrl?: string | null;
disabled?: boolean;
name: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="relative size-12 rounded-full transition outline-none hover:opacity-90 disabled:cursor-wait disabled:opacity-70"
disabled={props.disabled}
onClick={props.onClick}
title="Изменить аватар"
>
<AgentAvatar avatarUrl={props.avatarUrl} name={props.name} size="lg" />
</button>
);
}
function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgentSetupCard[]): TAgentSetupCard[] {
const cardsByAgentId = new Map<string, TAgentSetupCard>();
for (const card of persistedCards) {
cardsByAgentId.set(card.agent.id, card);
}
for (const card of createdCards) {
const persistedCard = cardsByAgentId.get(card.agent.id);
if (!persistedCard) {
cardsByAgentId.set(card.agent.id, card);
continue;
}
cardsByAgentId.set(card.agent.id, {
agent: persistedCard.agent,
setup: persistedCard.setup ?? card.setup,
tokens: mergeTokens(persistedCard.tokens, card.tokens),
});
}
return Array.from(cardsByAgentId.values()).filter((card) => card.tokens.length > 0);
}
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
const tokensById = new Map<string, TCodexAgentToken>();
for (const token of primaryTokens) tokensById.set(token.id, token);
for (const token of secondaryTokens) tokensById.set(token.id, tokensById.get(token.id) ?? token);
return Array.from(tokensById.values()).sort(
(leftToken, rightToken) => new Date(rightToken.created_at).getTime() - new Date(leftToken.created_at).getTime()
);
}
function upsertSetupCardToken(
cards: TAgentSetupCard[],
agent: TCodexAgent,
token: TCodexAgentToken,
setup?: TCodexAgentSetupPacket
): TAgentSetupCard[] {
const existingCard = cards.find((card) => card.agent.id === agent.id);
if (!existingCard) {
return [{ agent, setup, tokens: [token] }, ...cards];
}
return cards.map((card) =>
card.agent.id === agent.id
? {
agent,
setup: setup ?? card.setup,
tokens: mergeTokens([token], card.tokens),
}
: card
);
}
function readAvatarDataUrl(file: File): Promise<string> {
if (!file.type.startsWith("image/")) {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
}
if (file.size > MAX_AGENT_AVATAR_BYTES) {
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ."));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара."));
reader.readAsDataURL(file);
});
}
function showAvatarError(message?: string) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось загрузить аватар",
message: message ?? "Проверьте формат и размер изображения.",
});
}
type TCapabilityCardProps = {
description: string;
icon: typeof ShieldCheck;

View File

@ -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<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 }> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/revoke/`)
.then((response) => response?.data)

View File

@ -74,7 +74,7 @@ export const WORKSPACE_SETTINGS: Record<TWorkspaceSettingsTabs, TWorkspaceSettin
key: "codex-agent-api",
i18n_label: "workspace_settings.settings.codex_agent_api.title",
href: `/settings/codex-agent-api`,
access: [EUserWorkspaceRoles.ADMIN],
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/codex-agent-api/`,
},
};