feat: add Ops Codex terminal setup UI

This commit is contained in:
DCCONSTRUCTIONS 2026-06-24 20:12:09 +03:00
parent df4767a085
commit 763b90f3ba
7 changed files with 255 additions and 31 deletions

View File

@ -1,4 +1,5 @@
FROM python:3.12.10-alpine
LABEL nodedc.deploy.cache_bust="codex-agent-setup-20260624"
# set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
@ -44,6 +45,7 @@ COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
RUN grep -R "setup-codes" /code/plane/app/urls/codex_agents.py >/dev/null
COPY ./bin ./bin/

View File

@ -12,6 +12,7 @@ from plane.app.views import (
CodexAgentListEndpoint,
CodexAgentProjectAccessEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupCodeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,
@ -69,4 +70,9 @@ urlpatterns = [
CodexAgentSetupEndpoint.as_view(),
name="codex-agent-api-agent-setup",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/setup-codes/",
CodexAgentSetupCodeEndpoint.as_view(),
name="codex-agent-api-agent-setup-codes",
),
]

View File

@ -196,6 +196,7 @@ from .codex_agents import (
CodexAgentListEndpoint,
CodexAgentProjectAccessEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupCodeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,

View File

@ -563,3 +563,21 @@ class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")
class CodexAgentSetupCodeEndpoint(CodexAgentEntitledEndpoint):
@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:
return entitlement_error
payload = {
"expires_in_seconds": request.data.get("expires_in_seconds") or 600,
"token_name": request.data.get("token_name") or "Local Codex token",
}
return gateway_request(
"POST",
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup-codes",
payload,
)

View File

@ -1,4 +1,5 @@
FROM node:22-alpine AS base
LABEL nodedc.deploy.cache_bust="codex-agent-setup-20260624"
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
@ -37,6 +38,7 @@ RUN corepack enable pnpm
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN grep -R "Recommended terminal setup" /app/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx >/dev/null
# Fetch dependencies to cache store, then install offline with dev deps
RUN pnpm fetch --store-dir=/pnpm/store
@ -93,6 +95,7 @@ FROM nginx:1.27-alpine AS production
COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
RUN grep -R "Recommended terminal setup" /usr/share/nginx/html/assets >/dev/null
RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 3000

View File

@ -6,7 +6,7 @@
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck } from "lucide-react";
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck, Terminal } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -21,6 +21,7 @@ import {
type TCodexAgent,
type TCodexAgentGrant,
type TCodexAgentGrantableWorkspace,
type TCodexAgentSetupCode,
type TCodexAgentSetupPacket,
type TCodexAgentToken,
} from "@/services/workspace-codex-agent.service";
@ -59,7 +60,9 @@ type TProps = {
type TAgentSetupCard = {
agent: TCodexAgent;
grants: TCodexAgentGrant[];
installCommand?: string;
setup?: TCodexAgentSetupPacket;
setupCode?: TCodexAgentSetupCode;
tokens: TCodexAgentToken[];
};
@ -84,6 +87,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
const [creatingSetupCodeAgentIds, setCreatingSetupCodeAgentIds] = useState<Record<string, boolean>>({});
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
@ -221,19 +225,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
mode: "voluntary",
}
);
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
name: `${displayName} local token`,
});
setRevealedTokens((currentTokens) => ({
...currentTokens,
[tokenResponse.token_record.id]: tokenResponse.token,
}));
const [setupResponse, setupCodeResponse] = await Promise.all([
codexAgentService.getSetup(workspaceSlug, createResponse.agent.id),
codexAgentService.createSetupCode(workspaceSlug, createResponse.agent.id, {
expires_in_seconds: 600,
token_name: `${displayName} local token`,
}),
]);
setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(
upsertSetupCardSetupCode(
currentCards,
createResponse.agent,
tokenResponse.token_record,
tokenResponse.setup,
setupCodeResponse.setup_code_record,
setupCodeResponse.install.command,
setupResponse.setup,
grantsResponse.grants
)
);
@ -242,7 +247,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Codex agent создан",
message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.",
message:
"Скопируйте terminal-команду в локальный терминал. Setup-код действует 10 минут и используется один раз.",
});
} catch (error: any) {
setToast({
@ -255,6 +261,40 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
}
};
const handleCreateSetupCode = async (agent: TCodexAgent) => {
setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
const setupCodeResponse = await codexAgentService.createSetupCode(workspaceSlug, agent.id, {
expires_in_seconds: 600,
token_name: `${agent.display_name} local token`,
});
const currentCard = setupCards.find((card) => card.agent.id === agent.id);
setCreatedSetupCards((currentCards) =>
upsertSetupCardSetupCode(
currentCards,
agent,
setupCodeResponse.setup_code_record,
setupCodeResponse.install.command,
currentCard?.setup,
currentCard?.grants ?? []
)
);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Terminal-команда выпущена",
message: "Setup-код действует 10 минут и используется один раз.",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось выпустить terminal-команду",
message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
});
} finally {
setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: false }));
}
};
const handleCreateToken = async (agent: TCodexAgent) => {
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
@ -435,7 +475,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
<CapabilityCard
icon={KeyRound}
title="Локальный Codex"
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
description="Пользовательский Codex подключается по MCP endpoint через одноразовый setup-code или legacy agent token."
/>
</section>
@ -445,7 +485,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
<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. Аватар меняется кликом по кругу.
Задайте имя, выберите project grant и получите terminal-команду для локального Codex. Аватар
меняется кликом по кругу.
</p>
</div>
@ -514,11 +555,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
activeAgents.map((agent) => {
const draftName = getAgentDraftName(agentDraftNames, agent);
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
const isCreatingSetupCode = creatingSetupCodeAgentIds[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 agentGrants = setupCard?.grants ?? [];
const installCommand = setupCard?.installCommand;
const setupCode = setupCard?.setupCode;
const currentGrantKeys = getGrantedProjectKeys(agentGrants);
const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
@ -584,15 +628,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
>
Сохранить
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый токен
</Button>
<Button
variant="secondary"
size="sm"
@ -604,8 +639,18 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
</div>
<AgentTerminalSetupPanel
command={installCommand}
isCreating={isCreatingSetupCode}
setupCode={setupCode}
onCopyCommand={() => installCommand && void handleCopy(installCommand, "Terminal-команда")}
onCreate={() => void handleCreateSetupCode(agent)}
/>
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">Загрузка токена...</div>
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка legacy-токенов...
</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
@ -617,7 +662,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
return (
<div key={token.id} className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
Legacy agent token
</div>
<div className="relative">
<code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
@ -647,8 +692,19 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
})}
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
<div className="nodedc-settings-field flex flex-col gap-3 px-4 py-4 text-13 text-secondary sm:flex-row sm:items-center sm:justify-between">
<span>
Legacy-токен ещё не выпущен. Используйте его только для ручной настройки config.toml.
</span>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button shrink-0"
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Сгенерировать токен
</Button>
</div>
)}
@ -704,12 +760,74 @@ type TCodexConnectionGuideProps = {
onCopyConfig: () => void;
};
type TAgentTerminalSetupPanelProps = {
command?: string;
isCreating: boolean;
onCopyCommand: () => void;
onCreate: () => void;
setupCode?: TCodexAgentSetupCode;
};
function AgentTerminalSetupPanel(props: TAgentTerminalSetupPanelProps) {
const expiresAtLabel = props.setupCode?.expires_at ? new Date(props.setupCode.expires_at).toLocaleString() : null;
return (
<div className="nodedc-settings-field p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/8 text-tertiary">
<Terminal className="size-4" />
</span>
<div className="min-w-0">
<div className="text-13 font-semibold text-primary">Recommended terminal setup</div>
<div className="mt-1 text-12 text-tertiary">
Одноразовая npm-команда для локального терминала: создаст token, обновит Codex config и установит
ops-context skill.
</div>
</div>
</div>
<Button
variant={props.command ? "secondary" : "primary"}
size="sm"
className={props.command ? "nodedc-settings-chip" : "nodedc-settings-save-button"}
loading={props.isCreating}
onClick={props.onCreate}
>
{props.command ? "Обновить команду" : "Получить команду"}
</Button>
</div>
{props.command ? (
<div className="mt-4 grid gap-3">
<pre className="nodedc-settings-input font-mono max-h-40 w-full overflow-auto px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
{props.command}
</pre>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-12 text-tertiary">
{expiresAtLabel ? `Действует до ${expiresAtLabel}. ` : ""}
Команда не показывает agent token, использует npm package installer и сработает только один раз.
</div>
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyCommand}>
<Copy className="mr-2 size-3.5" />
Скопировать команду
</Button>
</div>
</div>
) : (
<div className="mt-4 rounded-2xl bg-white/[0.03] px-4 py-3 text-12 leading-5 text-tertiary">
Нажмите «Получить команду» и вставьте ее в терминал на машине, где запущен локальный Codex.
</div>
)}
</div>
);
}
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
return (
<div className="grid gap-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
<div className="text-14 font-semibold text-primary">Legacy manual setup</div>
</div>
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
MCP endpoint · {props.mcpEndpoint}
@ -763,7 +881,7 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
<p className="mb-3 text-13 leading-5 text-secondary">
Добавьте этот блок в конец существующего <code>config.toml</code>. Не заменяйте файл целиком. Если блок{" "}
<code>[mcp_servers.{CODEX_MCP_SERVER_NAME}]</code> уже есть замените только этот блок и его{" "}
<code>headers</code>.
<code>http_headers</code>.
</p>
<pre className="nodedc-settings-input font-mono w-full px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
{props.configSnippet}
@ -790,7 +908,7 @@ required = false
startup_timeout_sec = 20
tool_timeout_sec = 60
[mcp_servers.${CODEX_MCP_SERVER_NAME}.headers]
[mcp_servers.${CODEX_MCP_SERVER_NAME}.http_headers]
Authorization = "Bearer ndcag_ВАШ_УНИКАЛЬНЫЙ_ТОКЕН"
Accept = "application/json"
"MCP-Protocol-Version" = "2025-06-18"`;
@ -994,12 +1112,14 @@ function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgent
cardsByAgentId.set(card.agent.id, {
agent: persistedCard.agent,
grants: mergeAgentGrants(persistedCard.grants, card.grants),
installCommand: card.installCommand ?? persistedCard.installCommand,
setup: persistedCard.setup ?? card.setup,
setupCode: card.setupCode ?? persistedCard.setupCode,
tokens: mergeTokens(persistedCard.tokens, card.tokens),
});
}
return Array.from(cardsByAgentId.values()).filter((card) => card.tokens.length > 0);
return Array.from(cardsByAgentId.values());
}
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
@ -1029,13 +1149,42 @@ function upsertSetupCardToken(
? {
agent,
grants: mergeAgentGrants(card.grants, grants),
installCommand: card.installCommand,
setup: setup ?? card.setup,
setupCode: card.setupCode,
tokens: mergeTokens([token], card.tokens),
}
: card
);
}
function upsertSetupCardSetupCode(
cards: TAgentSetupCard[],
agent: TCodexAgent,
setupCode: TCodexAgentSetupCode,
installCommand: string,
setup?: TCodexAgentSetupPacket,
grants: TCodexAgentGrant[] = []
): TAgentSetupCard[] {
const existingCard = cards.find((card) => card.agent.id === agent.id);
if (!existingCard) {
return [{ agent, grants, installCommand, setup, setupCode, tokens: [] }, ...cards];
}
return cards.map((card) =>
card.agent.id === agent.id
? {
agent,
grants: mergeAgentGrants(card.grants, grants),
installCommand,
setup: setup ?? card.setup,
setupCode,
tokens: card.tokens,
}
: card
);
}
function flattenProjectAccessOptions(workspaceGroups: TCodexAgentGrantableWorkspace[]): TProjectAccessOption[] {
return workspaceGroups.flatMap((workspaceGroup) =>
workspaceGroup.projects.map((project) => ({

View File

@ -10,6 +10,7 @@ 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 TCodexAgentSetupCodeStatus = "active" | "used" | "expired" | "revoked";
export type TCodexAgent = {
id: string;
@ -45,6 +46,19 @@ export type TCodexAgentToken = {
created_at: string;
};
export type TCodexAgentSetupCode = {
id: string;
agent_id: string;
code_suffix: string | null;
status: TCodexAgentSetupCodeStatus;
token_name: string;
created_by_user_id: string | null;
expires_at: string;
used_at: string | null;
used_token_id: string | null;
created_at: string;
};
export type TCodexAgentSetupPacket = {
agents_md?: string;
codex_config_template?: Record<string, unknown>;
@ -67,6 +81,25 @@ export type TCodexAgentCreateTokenResponse = {
token_record: TCodexAgentToken;
};
export type TCodexAgentCreateSetupCodeResponse = {
ok: boolean;
install: {
command: string;
fallback_command?: string;
installer_kind?: "npm_registry" | "npm_tarball" | "python_script";
installer_url: string;
legacy_python_installer_url?: string;
mcp_server_name: string;
npm_spec?: string;
package_url?: string;
platform: "macos_linux";
registry_command?: string;
skill_name: string;
};
setup_code: string;
setup_code_record: TCodexAgentSetupCode;
};
export type TCodexAgentTokenListResponse = {
ok: boolean;
tokens: TCodexAgentToken[];
@ -222,6 +255,18 @@ export class WorkspaceCodexAgentService extends APIService {
});
}
async createSetupCode(
workspaceSlug: string,
agentId: string,
data: { expires_in_seconds?: number; token_name?: string }
): Promise<TCodexAgentCreateSetupCodeResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/setup-codes/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async listTokens(workspaceSlug: string, agentId: string): Promise<TCodexAgentTokenListResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`)
.then((response) => response?.data)