FEAT - TASKER: управление Codex Agent API через Gateway proxy

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 21:11:45 +03:00
parent 97566faba3
commit 3b8e0ea594
9 changed files with 728 additions and 1 deletions

View File

@ -79,6 +79,9 @@ x-app-env: &app-env
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3}
PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check}
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3}
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -117,3 +117,6 @@ PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consu
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5

View File

@ -94,4 +94,7 @@ PLANE_NODEDC_HANDOFF_URL=https://launcher.staging.nodedc.example/api/internal/ha
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
PLANE_NODEDC_WORKSPACE_POLICY_URL=https://launcher.staging.nodedc.example/api/internal/access/check
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
PLANE_NODEDC_AGENT_GATEWAY_URL=https://codex-api.staging.nodedc.example
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user

View File

@ -5,6 +5,7 @@
from .analytic import urlpatterns as analytic_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
from .codex_agents import urlpatterns as codex_agent_urls
from .cycle import urlpatterns as cycle_urls
from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
urlpatterns = [
*analytic_urls,
*asset_urls,
*codex_agent_urls,
*cycle_urls,
*estimate_urls,
*external_urls,

View File

@ -0,0 +1,54 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint,
CodexAgentListEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/codex-agent-api/agents/",
CodexAgentListEndpoint.as_view(),
name="codex-agent-api-agents",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/",
CodexAgentDetailEndpoint.as_view(),
name="codex-agent-api-agent-detail",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/revoke/",
CodexAgentRevokeEndpoint.as_view(),
name="codex-agent-api-agent-revoke",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/grants/",
CodexAgentGrantListEndpoint.as_view(),
name="codex-agent-api-agent-grants",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/",
CodexAgentTokenListEndpoint.as_view(),
name="codex-agent-api-agent-tokens",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/<uuid:token_id>/revoke/",
CodexAgentTokenRevokeEndpoint.as_view(),
name="codex-agent-api-agent-token-revoke",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/setup/",
CodexAgentSetupEndpoint.as_view(),
name="codex-agent-api-agent-setup",
),
]

View File

@ -174,6 +174,16 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint
from .codex_agents import (
CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint,
CodexAgentListEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,
)
from .page.base import (
PageViewSet,
PageFavoriteViewSet,

View File

@ -0,0 +1,292 @@
# Python imports
import os
from urllib.parse import quote
# Third party imports
import requests
from django.core.exceptions import ValidationError
from rest_framework import status
from rest_framework.response import Response
# Module imports
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
def get_gateway_config():
base_url = (
os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_URL", "").strip()
or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_URL", "").strip()
or os.environ.get("NODEDC_AGENT_GATEWAY_URL", "").strip()
).rstrip("/")
token = (
os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TOKEN", "").strip()
or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN", "").strip()
)
timeout = float(os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS", "5") or "5")
return base_url, token, timeout
def owner_path(user):
return quote(str(user.id), safe="")
def agent_path(agent_id):
return quote(str(agent_id), safe="")
def token_path(token_id):
return quote(str(token_id), safe="")
def require_gateway_config():
base_url, token, timeout = get_gateway_config()
if not base_url or not token:
return None, Response(
{
"ok": False,
"error": "codex_agent_gateway_not_configured",
"message": "NODE.DC Codex Agent Gateway URL/token is not configured.",
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return (base_url, token, timeout), None
def require_codex_agent_entitlement(user, slug):
workspace_policy = get_nodedc_workspace_creation_policy(user, workspace_slug=slug)
service_modules = workspace_policy.get("service_modules") or {}
if not service_modules.get("codex_agents"):
return None, Response(
{
"ok": False,
"error": "codex_agents_not_entitled",
"message": "Codex Agent API is not enabled for this NODE.DC user/workspace.",
},
status=status.HTTP_403_FORBIDDEN,
)
return workspace_policy, None
def require_workspace(slug):
try:
return Workspace.objects.get(slug=slug), None
except Workspace.DoesNotExist:
return None, Response(
{"ok": False, "error": "workspace_not_found"},
status=status.HTTP_404_NOT_FOUND,
)
def validate_project_in_workspace(workspace, project_id):
if not project_id:
return None
try:
exists = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).exists()
except ValidationError:
exists = False
if not exists:
return Response(
{
"ok": False,
"error": "project_not_found",
"message": "Project is not available in this workspace.",
},
status=status.HTTP_404_NOT_FOUND,
)
return None
def gateway_request(method, path, payload=None):
config, error_response = require_gateway_config()
if error_response is not None:
return error_response
base_url, token, timeout = config
try:
response = requests.request(
method,
f"{base_url}{path}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
json=payload,
timeout=timeout,
)
except requests.RequestException:
return Response(
{
"ok": False,
"error": "codex_agent_gateway_unavailable",
"message": "NODE.DC Codex Agent Gateway is unavailable.",
},
status=status.HTTP_502_BAD_GATEWAY,
)
try:
data = response.json()
except ValueError:
data = {
"ok": False,
"error": "codex_agent_gateway_invalid_response",
"message": "NODE.DC Codex Agent Gateway returned a non-JSON response.",
}
return Response(data, status=response.status_code)
class CodexAgentEntitledEndpoint(BaseAPIView):
def require_entitlement(self, request, slug):
_, entitlement_error = require_codex_agent_entitlement(request.user, slug)
return entitlement_error
class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents")
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
display_name = str(request.data.get("display_name") or "").strip()
if not display_name:
return Response(
{"ok": False, "error": "display_name_required"},
status=status.HTTP_400_BAD_REQUEST,
)
payload = {
"display_name": display_name,
"owner_email": request.user.email or None,
"avatar_url": request.data.get("avatar_url") or None,
}
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents", payload)
class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
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")
def patch(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
payload = {"actor_user_id": str(request.user.id)}
if "display_name" in request.data:
payload["display_name"] = request.data.get("display_name")
if "avatar_url" in request.data:
payload["avatar_url"] = request.data.get("avatar_url") or None
return gateway_request("PATCH", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}", payload)
class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request(
"POST",
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/revoke",
{"actor_user_id": str(request.user.id)},
)
class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
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")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
workspace, workspace_error = require_workspace(slug)
if workspace_error is not None:
return workspace_error
project_id = request.data.get("project_id")
project_error = validate_project_in_workspace(workspace, project_id)
if project_error is not None:
return project_error
payload = {
"workspace_slug": slug,
"project_id": str(project_id) if project_id else None,
"scopes": request.data.get("scopes") or [],
"mode": request.data.get("mode") or "voluntary",
}
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants", payload)
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
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")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
payload = {
"name": request.data.get("name") or "Local Codex token",
"expires_at": request.data.get("expires_at") or None,
}
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens", payload)
class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug, agent_id, token_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request(
"POST",
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens/{token_path(token_id)}/revoke",
{"actor_user_id": str(request.user.id)},
)
class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")

View File

@ -4,16 +4,40 @@
* See the LICENSE file for details.
*/
import { useMemo, 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";
// components
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import { ProjectService } from "@/services/project/project.service";
import {
WorkspaceCodexAgentService,
type TCodexAgentCreateTokenResponse,
} from "@/services/workspace-codex-agent.service";
import { WorkspaceService } from "@/services/workspace.service";
const TASK_AUTHOR_SCOPES = [
"workspace:read",
"project:read",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:structured_blocks:write",
];
const codexAgentService = new WorkspaceCodexAgentService();
const projectService = new ProjectService();
const workspaceService = new WorkspaceService();
type TProps = {
@ -23,12 +47,80 @@ type TProps = {
export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
const { showHeading = true, workspaceSlug } = props;
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [newAgentName, setNewAgentName] = useState("Local Codex");
const [selectedProjectId, setSelectedProjectId] = useState("");
const [tokenPacket, setTokenPacket] = useState<TCodexAgentCreateTokenResponse | null>(null);
const { currentWorkspace } = useWorkspace();
const { data: nodedcWorkspacePolicy, isLoading } = useSWR(
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
() => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
);
const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
const {
data: codexAgentsPayload,
error: codexAgentsError,
isLoading: areAgentsLoading,
mutate: mutateCodexAgents,
} = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () =>
codexAgentService.listAgents(workspaceSlug)
);
const { data: projects } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECTS_${workspaceSlug}` : null, () =>
projectService.getProjectsLite(workspaceSlug)
);
const activeAgents = useMemo(
() => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
[codexAgentsPayload?.agents]
);
const projectOptions = projects ?? [];
const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || "";
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.",
});
};
const handleCreateAgent = async () => {
const displayName = newAgentName.trim();
if (!displayName || !effectiveSelectedProjectId) return;
setIsCreatingAgent(true);
try {
const createResponse = await codexAgentService.createAgent(workspaceSlug, { display_name: displayName });
await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
project_id: effectiveSelectedProjectId,
scopes: TASK_AUTHOR_SCOPES,
mode: "voluntary",
});
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
name: `${displayName} local token`,
});
setTokenPacket(tokenResponse);
await mutateCodexAgents();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Codex agent создан",
message: "Token показан один раз. Скопируйте его в локальную конфигурацию Codex.",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось создать Codex agent",
message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный проект.",
});
} finally {
setIsCreatingAgent(false);
}
};
const handleRevokeAgent = async (agentId: string) => {
await codexAgentService.revokeAgent(workspaceSlug, agentId);
await mutateCodexAgents();
};
return (
<div className="flex w-full flex-col gap-7">
@ -52,7 +144,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
<p className="mt-2 max-w-3xl text-13 leading-5 text-secondary">
Доступ к модулю приходит из Launcher entitlement Operational Core Codex Agent API. Если entitlement
снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный модуль.
снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный
модуль.
</p>
</div>
<div className="nodedc-external-readonly-value shrink-0">
@ -81,6 +174,145 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
/>
</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.
</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>
<Button
variant="primary"
size="lg"
className="nodedc-settings-save-button min-w-[11rem]"
disabled={!newAgentName.trim() || !effectiveSelectedProjectId}
loading={isCreatingAgent}
onClick={handleCreateAgent}
>
Создать агента
</Button>
</div>
</div>
{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">
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()}
</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>
</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>
)}
</>
)}
</div>

View File

@ -0,0 +1,128 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { API_BASE_URL } from "@plane/constants";
import { APIService } from "@/services/api.service";
export type TCodexAgentStatus = "active" | "disabled" | "revoked";
export type TCodexAgentGrantMode = "voluntary" | "reporting";
export type TCodexAgentTokenStatus = "active" | "revoked" | "expired";
export type TCodexAgent = {
id: string;
owner_user_id: string;
owner_email: string | null;
display_name: string;
avatar_url: string | null;
status: TCodexAgentStatus;
created_at: string;
updated_at: string;
};
export type TCodexAgentGrant = {
id: string;
agent_id: string;
workspace_slug: string;
project_id: string | null;
scopes: string[];
mode: TCodexAgentGrantMode;
created_by_user_id: string;
created_at: string;
updated_at: string;
};
export type TCodexAgentToken = {
id: string;
agent_id: string;
name: string;
status: TCodexAgentTokenStatus;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
};
export type TCodexAgentSetupPacket = {
agents_md?: string;
codex_config_template?: Record<string, unknown>;
mcp_server?: {
name: string;
url: string;
headers: Record<string, string>;
};
};
export type TCodexAgentListResponse = {
ok: boolean;
agents: TCodexAgent[];
};
export type TCodexAgentCreateTokenResponse = {
ok: boolean;
setup?: TCodexAgentSetupPacket;
token: string;
token_record: TCodexAgentToken;
};
export class WorkspaceCodexAgentService extends APIService {
constructor() {
super(API_BASE_URL);
}
async listAgents(workspaceSlug: string): Promise<TCodexAgentListResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createAgent(
workspaceSlug: string,
data: { display_name: string; avatar_url?: string | null }
): Promise<{ ok: boolean; agent: TCodexAgent }> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async upsertGrant(
workspaceSlug: string,
agentId: string,
data: {
mode?: TCodexAgentGrantMode;
project_id?: string | null;
scopes: string[];
}
): Promise<{ ok: boolean; grant: TCodexAgentGrant }> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/grants/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createToken(
workspaceSlug: string,
agentId: string,
data: { name?: string }
): Promise<TCodexAgentCreateTokenResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`, data)
.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)
.catch((error) => {
throw error?.response?.data;
});
}
}