From 763b90f3ba6df6ee07b57e5267d5dff5a025ed0c Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 24 Jun 2026 20:12:09 +0300 Subject: [PATCH] feat: add Ops Codex terminal setup UI --- plane-src/apps/api/Dockerfile.api | 2 + .../apps/api/plane/app/urls/codex_agents.py | 6 + .../apps/api/plane/app/views/__init__.py | 1 + .../apps/api/plane/app/views/codex_agents.py | 18 ++ plane-src/apps/web/Dockerfile.web.nas-legacy | 3 + .../settings/codex-agent-api-settings.tsx | 211 +++++++++++++++--- .../services/workspace-codex-agent.service.ts | 45 ++++ 7 files changed, 255 insertions(+), 31 deletions(-) diff --git a/plane-src/apps/api/Dockerfile.api b/plane-src/apps/api/Dockerfile.api index b7861dd..0104499 100644 --- a/plane-src/apps/api/Dockerfile.api +++ b/plane-src/apps/api/Dockerfile.api @@ -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/ diff --git a/plane-src/apps/api/plane/app/urls/codex_agents.py b/plane-src/apps/api/plane/app/urls/codex_agents.py index ff3077a..fd9e87d 100644 --- a/plane-src/apps/api/plane/app/urls/codex_agents.py +++ b/plane-src/apps/api/plane/app/urls/codex_agents.py @@ -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//codex-agent-api/agents//setup-codes/", + CodexAgentSetupCodeEndpoint.as_view(), + name="codex-agent-api-agent-setup-codes", + ), ] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index cb04c0e..7a1d5da 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -196,6 +196,7 @@ from .codex_agents import ( CodexAgentListEndpoint, CodexAgentProjectAccessEndpoint, CodexAgentRevokeEndpoint, + CodexAgentSetupCodeEndpoint, CodexAgentSetupEndpoint, CodexAgentTokenListEndpoint, CodexAgentTokenRevokeEndpoint, diff --git a/plane-src/apps/api/plane/app/views/codex_agents.py b/plane-src/apps/api/plane/app/views/codex_agents.py index 12b0f1c..e001db0 100644 --- a/plane-src/apps/api/plane/app/views/codex_agents.py +++ b/plane-src/apps/api/plane/app/views/codex_agents.py @@ -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, + ) diff --git a/plane-src/apps/web/Dockerfile.web.nas-legacy b/plane-src/apps/web/Dockerfile.web.nas-legacy index 982e63c..bbbf456 100644 --- a/plane-src/apps/web/Dockerfile.web.nas-legacy +++ b/plane-src/apps/web/Dockerfile.web.nas-legacy @@ -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 diff --git a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx index 69f2e45..44be174 100644 --- a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx @@ -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([]); const [revealedTokens, setRevealedTokens] = useState>({}); const [updatingAgentIds, setUpdatingAgentIds] = useState>({}); + const [creatingSetupCodeAgentIds, setCreatingSetupCodeAgentIds] = useState>({}); const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState>({}); const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState(null); const [projectGrantDrafts, setProjectGrantDrafts] = useState>({}); @@ -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 @@ -445,7 +485,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
Создать агента workspace

- Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу. + Задайте имя, выберите project grant и получите terminal-команду для локального Codex. Аватар + меняется кликом по кругу.

@@ -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 > Сохранить - )} @@ -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 ( +
+
+
+ + + +
+
Recommended terminal setup
+
+ Одноразовая npm-команда для локального терминала: создаст token, обновит Codex config и установит + ops-context skill. +
+
+
+ +
+ + {props.command ? ( +
+
+            {props.command}
+          
+
+
+ {expiresAtLabel ? `Действует до ${expiresAtLabel}. ` : ""} + Команда не показывает agent token, использует npm package installer и сработает только один раз. +
+ +
+
+ ) : ( +
+ Нажмите «Получить команду» и вставьте ее в терминал на машине, где запущен локальный Codex. +
+ )} +
+ ); +} + function CodexConnectionGuide(props: TCodexConnectionGuideProps) { return (
-
Подключение локального Codex
+
Legacy manual setup
MCP endpoint · {props.mcpEndpoint} @@ -763,7 +881,7 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {

Добавьте этот блок в конец существующего config.toml. Не заменяйте файл целиком. Если блок{" "} [mcp_servers.{CODEX_MCP_SERVER_NAME}] уже есть — замените только этот блок и его{" "} - headers. + http_headers.

           {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) => ({
diff --git a/plane-src/apps/web/core/services/workspace-codex-agent.service.ts b/plane-src/apps/web/core/services/workspace-codex-agent.service.ts
index 6e956b0..037c50f 100644
--- a/plane-src/apps/web/core/services/workspace-codex-agent.service.ts
+++ b/plane-src/apps/web/core/services/workspace-codex-agent.service.ts
@@ -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;
@@ -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 {
+    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 {
     return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`)
       .then((response) => response?.data)