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