FEAT - TASKER: управление Codex Agent API через Gateway proxy
This commit is contained in:
+233
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user