From 5e79a53e13d63e68d4a9c5bfc0f9b41adb120de3 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 24 Jun 2026 20:12:09 +0300 Subject: [PATCH] feat: add Ops Codex npm setup flow --- .env.example | 3 + .env.synology.example | 3 + docker-compose.local.yml | 2 + docker-compose.synology.yml | 2 + migrations/005_agent_setup_codes.sql | 17 + src/app.ts | 4 + src/assets/codex-npm/README.md | 36 + src/assets/codex-npm/bin/nodedc-ops-codex.mjs | 737 ++++++++++++++++++ src/assets/codex-npm/package.json | 29 + src/config.ts | 3 + src/mcp/tool-runtime.ts | 56 +- src/repositories/agents.ts | 226 +++++- src/routes/agents.ts | 147 +++- src/routes/install.ts | 330 ++++++++ src/security/agent-access.ts | 20 + src/security/tokens.ts | 9 + 16 files changed, 1596 insertions(+), 28 deletions(-) create mode 100644 migrations/005_agent_setup_codes.sql create mode 100644 src/assets/codex-npm/README.md create mode 100644 src/assets/codex-npm/bin/nodedc-ops-codex.mjs create mode 100644 src/assets/codex-npm/package.json create mode 100644 src/routes/install.ts create mode 100644 src/security/agent-access.ts diff --git a/.env.example b/.env.example index 3cd6096..04ec04d 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ LOG_LEVEL=info # explicit localhost/LAN/Tailscale URL only for a named local/tunnel profile. NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-gateway-internal-token +# tarball = self-host fallback, registry = short npx command after @nodedc/ops-codex is published. +NODEDC_OPS_CODEX_INSTALL_CHANNEL=tarball +NODEDC_OPS_CODEX_NPM_SPEC=@nodedc/ops-codex NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200 NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc diff --git a/.env.synology.example b/.env.synology.example index 46aaf5a..ba5951e 100644 --- a/.env.synology.example +++ b/.env.synology.example @@ -7,6 +7,9 @@ LOG_LEVEL=info NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token +# Use registry after @nodedc/ops-codex is published to npm. +NODEDC_OPS_CODEX_INSTALL_CHANNEL=tarball +NODEDC_OPS_CODEX_NPM_SPEC=@nodedc/ops-codex NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200 NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080 NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0728604..6ef2c22 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -27,6 +27,8 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-local-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN:-local-dev-codex-agent-gateway-token-change-me} + NODEDC_OPS_CODEX_INSTALL_CHANNEL: ${NODEDC_OPS_CODEX_INSTALL_CHANNEL:-tarball} + NODEDC_OPS_CODEX_NPM_SPEC: ${NODEDC_OPS_CODEX_NPM_SPEC:-@nodedc/ops-codex} NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://launcher.local.nodedc} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://task.local.nodedc} diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml index 1867291..cd46f29 100644 --- a/docker-compose.synology.yml +++ b/docker-compose.synology.yml @@ -25,6 +25,8 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN} + NODEDC_OPS_CODEX_INSTALL_CHANNEL: ${NODEDC_OPS_CODEX_INSTALL_CHANNEL:-registry} + NODEDC_OPS_CODEX_NPM_SPEC: ${NODEDC_OPS_CODEX_NPM_SPEC:-@nodedc/ops-codex} NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090} diff --git a/migrations/005_agent_setup_codes.sql b/migrations/005_agent_setup_codes.sql new file mode 100644 index 0000000..8d9bbc9 --- /dev/null +++ b/migrations/005_agent_setup_codes.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS agent_setup_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + code_hash text NOT NULL UNIQUE, + code_suffix text, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'used', 'expired', 'revoked')), + token_name text NOT NULL DEFAULT 'Local Codex token', + created_by_user_id text, + expires_at timestamptz NOT NULL, + used_at timestamptz, + used_token_id uuid REFERENCES agent_tokens(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agent_setup_codes_agent_id_idx ON agent_setup_codes(agent_id); +CREATE INDEX IF NOT EXISTS agent_setup_codes_status_idx ON agent_setup_codes(status); +CREATE INDEX IF NOT EXISTS agent_setup_codes_expires_at_idx ON agent_setup_codes(expires_at); diff --git a/src/app.ts b/src/app.ts index 20b8ae3..9015c03 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { ToolExecutionInputError } from "./mcp/tool-runtime.js"; import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerHealthRoutes } from "./routes/health.js"; +import { registerInstallerRoutes } from "./routes/install.js"; import { registerMcpRoutes } from "./routes/mcp.js"; import { registerPublicRoutes } from "./routes/public.js"; import { registerToolRoutes } from "./routes/tools.js"; @@ -127,9 +128,12 @@ export async function buildApp(config: AppConfig): Promise { }); await registerPublicRoutes(app); + await registerInstallerRoutes(app, { publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL }); await registerHealthRoutes(app, config, pool); await registerAgentRoutes(app, { agentsRepository, + codexInstallChannel: config.NODEDC_OPS_CODEX_INSTALL_CHANNEL, + codexNpmSpec: config.NODEDC_OPS_CODEX_NPM_SPEC, publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL, internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN, aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS, diff --git a/src/assets/codex-npm/README.md b/src/assets/codex-npm/README.md new file mode 100644 index 0000000..a53e3e6 --- /dev/null +++ b/src/assets/codex-npm/README.md @@ -0,0 +1,36 @@ +# NODE.DC Ops Codex + +Installs NODE.DC Ops MCP access into a local Codex Desktop/CLI configuration. + +## Setup + +Generate a one-time setup code in NODE.DC Ops, then run: + +```sh +npx --yes @nodedc/ops-codex setup ndcsetup_... +``` + +The setup command redeems the one-time code, writes the `nodedc-ops-agent` MCP server block into `~/.codex/config.toml`, installs the `ops-context` skill, and checks the gateway MCP tool list. + +Restart Codex Desktop after setup. A new chat is not enough if the existing Desktop process already loaded MCP config. + +The installer auto-detects existing Codex homes. It checks `$CODEX_HOME`, `~/.codex`, common OS app-data Codex folders, and portable-style `.codex` folders near the current directory. If multiple existing Codex homes are found, setup writes all of them. If none are found, setup creates `~/.codex`. + +## Commands + +```sh +ops-codex setup +ops-codex status +ops-codex doctor +``` + +`doctor` checks the local config, installed skill, and MCP connectivity. + +## Options + +```sh +--gateway Ops Agent Gateway URL. Defaults to https://ops-agents.nodedc.ru. +--codex-home Codex home directory. Overrides auto-discovery. +``` + +For an unusual portable Codex layout, run setup from the portable app folder or pass `--codex-home ` explicitly. diff --git a/src/assets/codex-npm/bin/nodedc-ops-codex.mjs b/src/assets/codex-npm/bin/nodedc-ops-codex.mjs new file mode 100644 index 0000000..40a06d8 --- /dev/null +++ b/src/assets/codex-npm/bin/nodedc-ops-codex.mjs @@ -0,0 +1,737 @@ +#!/usr/bin/env node +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_GATEWAY = "https://ops-agents.nodedc.ru"; +const SERVER_NAME = "nodedc-ops-agent"; +const SKILL_NAME = "ops-context"; +const MCP_PROTOCOL_VERSION = "2025-06-18"; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(args.command); + return; + } + + if (args.command === "setup") { + await runSetup(args); + return; + } + + if (args.command === "status") { + const report = await inspectLocalInstalls(args.codexHome, { smoke: false }); + printInstallReport(report); + process.exitCode = report.ok ? 0 : 1; + return; + } + + if (args.command === "doctor") { + const report = await inspectLocalInstalls(args.codexHome, { smoke: true }); + printInstallReport(report); + process.exitCode = report.ok ? 0 : 1; + return; + } + + throw new UsageError(`Unknown command: ${args.command}`); +} + +async function runSetup(args) { + if (!args.setupCode) { + throw new UsageError("Missing setup code. Use: ops-codex setup "); + } + + const gateway = String(args.gateway || process.env.NODEDC_OPS_AGENT_GATEWAY || DEFAULT_GATEWAY).replace(/\/+$/, ""); + const redeemPayload = await redeemSetupCode(gateway, args.setupCode); + const token = redeemPayload.token; + const setup = redeemPayload.setup || {}; + const mcpServer = setup.mcp_server || {}; + const endpoint = String(mcpServer.url || `${gateway}/mcp`); + const agentsMd = setup.agents_md || defaultSkillBody(endpoint); + + const targets = await resolveCodexTargets(args.codexHome); + + for (const target of targets) { + const configPath = path.join(target.codexHome, "config.toml"); + const skillPath = path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md"); + await writeCodexConfig(configPath, endpoint, token); + await writeSkill(skillPath, agentsMd, endpoint); + } + const toolCount = await smokeToolsList(endpoint, token); + + console.log("NODE.DC Ops Codex setup complete."); + for (const target of targets) { + console.log("Config:", path.join(target.codexHome, "config.toml")); + console.log("Skill:", path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md")); + console.log("Target:", target.reason); + } + console.log("Gateway MCP smoke tools:", toolCount); + console.log("Run: ops-codex doctor"); + console.log("Restart Codex Desktop completely before testing. A new chat is not enough if the Desktop process already loaded MCP config."); + console.log("In the next session, tool discovery must expose tasker_list_projects. If it does not, Codex has not loaded the NODE.DC Ops MCP server."); +} + +function parseArgs(rawArgs) { + let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + let command = "setup"; + + if (args[0] === "setup" || args[0] === "status" || args[0] === "doctor") { + command = args[0]; + args = args.slice(1); + } else if (args[0] === "help") { + return { codexHome: "", command: "help", gateway: "", help: true, setupCode: "" }; + } + + const parsed = { + codexHome: "", + command, + gateway: "", + help: false, + setupCode: "", + }; + const positional = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "-h" || arg === "--help") { + parsed.help = true; + continue; + } + if (arg === "--gateway") { + parsed.gateway = requireValue(args, ++i, "--gateway"); + continue; + } + if (arg.startsWith("--gateway=")) { + parsed.gateway = arg.slice("--gateway=".length); + continue; + } + if (arg === "--setup-code" || arg === "--code") { + parsed.setupCode = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--setup-code=")) { + parsed.setupCode = arg.slice("--setup-code=".length); + continue; + } + if (arg.startsWith("--code=")) { + parsed.setupCode = arg.slice("--code=".length); + continue; + } + if (arg === "--codex-home") { + parsed.codexHome = requireValue(args, ++i, "--codex-home"); + continue; + } + if (arg.startsWith("--codex-home=")) { + parsed.codexHome = arg.slice("--codex-home=".length); + continue; + } + if (arg.startsWith("-")) { + throw new UsageError(`Unknown argument: ${arg}`); + } + positional.push(arg); + } + + if (parsed.command === "setup") { + if (!parsed.setupCode && positional[0]) { + parsed.setupCode = positional[0]; + } + if (positional.length > 1) { + throw new UsageError(`Unexpected extra argument: ${positional[1]}`); + } + } else if (positional.length) { + throw new UsageError(`${parsed.command} does not accept positional arguments.`); + } + + return parsed; +} + +function requireValue(args, index, flag) { + const value = args[index]; + if (!value || value.startsWith("--")) { + throw new UsageError(`Missing value for ${flag}.`); + } + return value; +} + +function printHelp(command = "") { + if (command === "status") { + console.log(`Show local NODE.DC Ops Codex install status. + +Usage: + ops-codex status [--codex-home ] +`); + return; + } + + if (command === "doctor") { + console.log(`Check local NODE.DC Ops Codex install and MCP connectivity. + +Usage: + ops-codex doctor [--codex-home ] +`); + return; + } + + console.log(`Install NODE.DC Ops MCP access into local Codex. + +Usage: + ops-codex setup [--gateway ] [--codex-home ] + ops-codex setup --code [--gateway ] [--codex-home ] + ops-codex status [--codex-home ] + ops-codex doctor [--codex-home ] + +Registry form after publish: + npx --yes @nodedc/ops-codex setup + +Self-host fallback: + npm exec --yes --package=/install/nodedc-ops-codex-setup.tgz -- ops-codex setup + +Options: + --code, --setup-code One-time setup code generated by NODE.DC Ops. + --gateway Ops Agent Gateway URL. Defaults to ${DEFAULT_GATEWAY}. + --codex-home Codex home directory. Overrides auto-discovery. + -h, --help Show this help. + +Codex home discovery: + By default, the installer writes every existing Codex-like home it can detect: + $CODEX_HOME, ~/.codex, common OS app-data Codex folders, and portable-style + .codex folders near the current directory. If none exist, it creates ~/.codex. + +Legacy compatibility: + nodedc-ops-codex --setup-code +`); +} + +async function redeemSetupCode(gateway, setupCode) { + let response; + try { + response = await fetch(`${gateway}/api/v1/setup-codes/redeem`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ setup_code: setupCode }), + }); + } catch (error) { + throw new Error(`Setup code redeem failed: ${error.message}`); + } + + const bodyText = await response.text(); + let data; + try { + data = bodyText ? JSON.parse(bodyText) : {}; + } catch { + throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`); + } + + if (!response.ok) { + throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`); + } + + if (!data.ok || !data.token) { + throw new Error(`Setup code redeem failed: ${JSON.stringify(data)}`); + } + + return data; +} + +async function resolveCodexTargets(codexHomeArg) { + if (codexHomeArg) { + return [{ codexHome: expandHome(codexHomeArg), reason: "--codex-home" }]; + } + + if (process.env.CODEX_HOME) { + return [{ codexHome: expandHome(process.env.CODEX_HOME), reason: "CODEX_HOME" }]; + } + + const candidates = buildCodexHomeCandidates(); + const existing = []; + for (const candidate of candidates) { + if (await looksLikeCodexHome(candidate.codexHome)) { + existing.push(candidate); + } + } + + const dedupedExisting = dedupeTargets(existing); + if (dedupedExisting.length > 0) { + return dedupedExisting; + } + + return [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }]; +} + +function buildCodexHomeCandidates() { + const home = os.homedir(); + const candidates = [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }]; + + if (process.platform === "darwin") { + candidates.push( + { + codexHome: path.join(home, "Library", "Application Support", "Codex"), + reason: "macOS Application Support Codex", + }, + { + codexHome: path.join(home, "Library", "Application Support", "OpenAI", "Codex"), + reason: "macOS Application Support OpenAI Codex", + } + ); + } + + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming"); + const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + candidates.push( + { codexHome: path.join(appData, "Codex"), reason: "Windows APPDATA Codex" }, + { codexHome: path.join(appData, "OpenAI", "Codex"), reason: "Windows APPDATA OpenAI Codex" }, + { codexHome: path.join(localAppData, "Codex"), reason: "Windows LOCALAPPDATA Codex" }, + { codexHome: path.join(localAppData, "OpenAI", "Codex"), reason: "Windows LOCALAPPDATA OpenAI Codex" } + ); + } + + for (const portableHome of portableCodexHomeCandidates(process.cwd())) { + candidates.push(portableHome); + } + + return dedupeTargets(candidates); +} + +function portableCodexHomeCandidates(startDir) { + const candidates = []; + let current = path.resolve(startDir); + const stopAt = path.parse(current).root; + + for (let depth = 0; depth < 4; depth += 1) { + candidates.push( + { codexHome: path.join(current, ".codex"), reason: "portable .codex near current directory" }, + { codexHome: path.join(current, "data", ".codex"), reason: "portable data/.codex near current directory" }, + { codexHome: path.join(current, "profile", ".codex"), reason: "portable profile/.codex near current directory" }, + { codexHome: path.join(current, "portable", ".codex"), reason: "portable portable/.codex near current directory" } + ); + + if (current === stopAt) { + break; + } + current = path.dirname(current); + } + + return candidates; +} + +function defaultCodexHome() { + return path.join(os.homedir(), ".codex"); +} + +async function looksLikeCodexHome(codexHome) { + if (await pathExists(path.join(codexHome, "config.toml"))) { + return true; + } + if (await pathExists(path.join(codexHome, "skills"))) { + return true; + } + const baseName = path.basename(codexHome).toLowerCase(); + return baseName === ".codex" && (await pathExists(codexHome)); +} + +async function pathExists(targetPath) { + try { + await stat(targetPath); + return true; + } catch (error) { + if (error && error.code === "ENOENT") { + return false; + } + throw error; + } +} + +function dedupeTargets(targets) { + const seen = new Set(); + const result = []; + for (const target of targets) { + const key = normalizePathKey(target.codexHome); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push({ codexHome: path.resolve(target.codexHome), reason: target.reason }); + } + return result; +} + +function normalizePathKey(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function expandHome(value) { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return path.resolve(value); +} + +async function writeCodexConfig(configPath, endpoint, token) { + await mkdir(path.dirname(configPath), { recursive: true }); + const original = await readTextIfExists(configPath); + let backupPath = ""; + if (original !== null) { + backupPath = `${configPath}.nodedc-bak`; + await writeFile(backupPath, original, "utf8"); + } + + let nextText = stripServerSections(original || "").trimEnd(); + if (nextText) { + nextText += "\n\n"; + } + nextText += buildMcpConfigBlock(endpoint, token); + nextText += "\n"; + await writeFile(configPath, nextText, "utf8"); + + if (backupPath) { + console.log("Backup:", backupPath); + } +} + +async function readTextIfExists(filePath) { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if (error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +function stripServerSections(text) { + const target = `mcp_servers.${SERVER_NAME}`; + const kept = []; + let skip = false; + + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/); + if (match) { + const section = match[1].trim(); + skip = section === target || section.startsWith(`${target}.`); + } + if (!skip) { + kept.push(line); + } + } + + return kept.join("\n"); +} + +function buildMcpConfigBlock(endpoint, token) { + return [ + `[mcp_servers.${SERVER_NAME}]`, + `url = ${tomlString(endpoint)}`, + "enabled = true", + "required = false", + "startup_timeout_sec = 20", + "tool_timeout_sec = 60", + "", + `[mcp_servers.${SERVER_NAME}.http_headers]`, + `Authorization = ${tomlString(`Bearer ${token}`)}`, + `Accept = ${tomlString("application/json")}`, + `${tomlString("MCP-Protocol-Version")} = ${tomlString(MCP_PROTOCOL_VERSION)}`, + ].join("\n"); +} + +function tomlString(value) { + return JSON.stringify(String(value)); +} + +async function writeSkill(skillPath, agentsMd, endpoint) { + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, buildSkillBody(agentsMd, endpoint), "utf8"); +} + +function buildSkillBody(agentsMd, endpoint) { + return [ + "---", + "name: ops-context", + "description: Use when working with NODE.DC Ops/Tasker cards, project cards, checkers, labels, comments, or when the user asks for ops-context. Always use the direct nodedc-ops-agent tasker_* MCP tools and never the old codex_apps OPS widgets.", + "---", + "", + "# ops-context", + "", + "Use this skill when the user asks to work with NODE.DC Ops, Tasker cards, project cards, checkers, labels, comments, or asks for ops-context.", + "", + "## Hard Rules", + "", + "- Treat Ops MCP as the source of truth for cards and project context.", + "- Use only the direct nodedc-ops-agent MCP tools for Ops cards. The expected tool names are tasker_get_agent_instructions, tasker_list_projects, tasker_get_project_context, tasker_search_issues, and the other tasker_* tools listed below.", + "- Do not use Codex Apps workspace widgets for Ops: never call tools from codex_apps servers named nodedc_ops_readonly, nodedc_ops_gateway_readonly, ops-readonly, or ops_gateway_readonly. They are known-broken for Tasker cards and waste context.", + "- Do not open current workspace widgets, CodexPro cards, .ai-bridge handoff files, local thread lists, or broad filesystem searches to answer Ops card requests.", + "- MCP tools can be lazy-loaded. If mcp__nodedc_ops_agent.tasker_list_projects is not already visible in the active tools, do not answer yet.", + "- First call the available tool discovery/search tool, normally tool_search.tool_search_tool, with query exactly: tasker_list_projects nodedc-ops-agent NODE.DC Ops MCP", + "- After discovery returns NODE.DC Ops tools, immediately call mcp__nodedc_ops_agent.tasker_get_agent_instructions, then mcp__nodedc_ops_agent.tasker_list_projects, then mcp__nodedc_ops_agent.tasker_get_project_context for the target project.", + "- Resolve workspace/project from tasker_list_projects and granted project context. Do not guess a workspace slug from user wording.", + "- Stop only if no discovery/search tool is available, or discovery returns no mcp__nodedc_ops_agent tasker_* tools. Then tell the user: 'NODE.DC Ops MCP is installed in config.toml, but this Codex session did not expose the nodedc-ops-agent tools after tool discovery.' Do not fall back to filesystem search, config.toml parsing, curl, or Codex Apps widgets unless the user explicitly asks to debug installation.", + "- Never print Authorization headers, setup codes, or agent tokens.", + "- Every write must use the official Tasker MCP write tool and a fresh idempotency_key.", + "- Do not delete or archive cards, comments, labels, projects, states, members, or workspaces.", + "", + "## Installed Endpoint", + "", + endpoint, + "", + "## Gateway Rules Snapshot", + "", + agentsMd.trim(), + "", + ].join("\n"); +} + +function defaultSkillBody(endpoint) { + return [ + "# NODE.DC Ops Agent Rules", + "", + `MCP endpoint: ${endpoint}`, + "", + "Call tasker_get_agent_instructions, tasker_list_projects, and tasker_get_project_context before writing Tasker cards.", + ].join("\n"); +} + +async function inspectLocalInstall(codexHome, options) { + const configPath = path.join(codexHome, "config.toml"); + const skillPath = path.join(codexHome, "skills", SKILL_NAME, "SKILL.md"); + const checks = []; + const configText = await readTextIfExists(configPath); + const skillText = await readTextIfExists(skillPath); + const parsedConfig = configText ? parseCodexMcpConfig(configText) : null; + const endpoint = parsedConfig?.server.url || ""; + const authorization = parsedConfig?.headers.Authorization || ""; + + checks.push({ ok: Boolean(configText), name: "config", detail: configPath }); + checks.push({ + ok: Boolean(parsedConfig?.server), + name: "mcp server", + detail: parsedConfig?.server ? SERVER_NAME : "missing [mcp_servers.nodedc-ops-agent]", + }); + checks.push({ + ok: Boolean(endpoint), + name: "endpoint", + detail: endpoint || "missing url", + }); + checks.push({ + ok: Boolean(parsedConfig?.headers && authorization), + name: "authorization", + detail: authorization ? "present" : "missing http_headers.Authorization", + }); + checks.push({ + ok: Boolean(skillText), + name: "skill", + detail: skillPath, + }); + checks.push({ + ok: Boolean(skillText?.startsWith("---\nname: ops-context")), + name: "skill frontmatter", + detail: skillText ? "present" : "not checked", + }); + checks.push({ + ok: Boolean(skillText?.includes("tool_search.tool_search_tool")), + name: "lazy discovery rule", + detail: skillText ? "present" : "not checked", + }); + + let smoke = null; + if (options.smoke) { + if (endpoint && authorization) { + try { + const toolCount = await smokeToolsListWithAuthorization(endpoint, authorization); + smoke = { ok: true, detail: `${toolCount} MCP tools` }; + } catch (error) { + smoke = { ok: false, detail: error.message || String(error) }; + } + } else { + smoke = { ok: false, detail: "endpoint or Authorization header is missing" }; + } + checks.push({ ok: smoke.ok, name: "mcp smoke", detail: smoke.detail }); + } + + return { + codexHome, + configPath, + endpoint, + ok: checks.every((check) => check.ok), + skillPath, + checks, + }; +} + +async function inspectLocalInstalls(codexHomeArg, options) { + const targets = await resolveCodexTargets(codexHomeArg); + const reports = []; + for (const target of targets) { + reports.push({ + target, + report: await inspectLocalInstall(target.codexHome, options), + }); + } + + return { + ok: reports.some((entry) => entry.report.ok), + reports, + }; +} + +function parseCodexMcpConfig(text) { + const target = `mcp_servers.${SERVER_NAME}`; + const headers = `${target}.http_headers`; + let current = ""; + const result = { headers: {}, server: {} }; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const sectionMatch = line.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + current = sectionMatch[1].trim(); + continue; + } + if (current !== target && current !== headers) { + continue; + } + const keyValue = line.match(/^("[^"]+"|[A-Za-z0-9_.-]+)\s*=\s*(.+)$/); + if (!keyValue) { + continue; + } + const key = parseTomlKey(keyValue[1]); + const value = parseTomlValue(keyValue[2]); + if (current === target) { + result.server[key] = value; + } else { + result.headers[key] = value; + } + } + + return result; +} + +function parseTomlKey(value) { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return JSON.parse(trimmed); + } + return trimmed; +} + +function parseTomlValue(value) { + const trimmed = stripTomlInlineComment(value.trim()); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed.slice(1, -1); + } + } + if (trimmed === "true") return true; + if (trimmed === "false") return false; + return trimmed; +} + +function stripTomlInlineComment(value) { + let inString = false; + let escaped = false; + for (let i = 0; i < value.length; i += 1) { + const char = value[i]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (char === "#" && !inString) { + return value.slice(0, i).trimEnd(); + } + } + return value; +} + +function printInstallReport(result) { + console.log("NODE.DC Ops Codex status"); + for (const entry of result.reports) { + const report = entry.report; + console.log("Codex home:", report.codexHome); + console.log("Target:", entry.target.reason); + for (const check of report.checks) { + console.log(`${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`); + } + } + if (result.ok) { + console.log("Result: ready"); + } else { + console.log("Result: needs attention"); + } +} + +async function smokeToolsList(endpoint, token) { + return smokeToolsListWithAuthorization(endpoint, `Bearer ${token}`); +} + +async function smokeToolsListWithAuthorization(endpoint, authorization) { + let response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: authorization, + Accept: "application/json", + "Content-Type": "application/json", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + } catch (error) { + throw new Error(`MCP smoke check failed: ${error.message}`); + } + + const bodyText = await response.text(); + let data; + try { + data = bodyText ? JSON.parse(bodyText) : {}; + } catch { + throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`); + } + + if (!response.ok) { + throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`); + } + if (data.error) { + throw new Error(`MCP smoke check failed: ${JSON.stringify(data.error)}`); + } + + const tools = data.result?.tools || []; + if (!Array.isArray(tools) || tools.length === 0) { + throw new Error("MCP smoke check failed: tools/list returned no tools."); + } + return tools.length; +} + +class UsageError extends Error {} + +main().catch((error) => { + if (error instanceof UsageError) { + console.error(error.message); + console.error("Run ops-codex --help for usage."); + process.exit(2); + } + console.error(error.message || String(error)); + process.exit(1); +}); diff --git a/src/assets/codex-npm/package.json b/src/assets/codex-npm/package.json new file mode 100644 index 0000000..b16f05d --- /dev/null +++ b/src/assets/codex-npm/package.json @@ -0,0 +1,29 @@ +{ + "name": "@nodedc/ops-codex", + "version": "0.1.2", + "description": "Install NODE.DC Ops MCP access into local Codex.", + "type": "module", + "bin": { + "ops-codex": "./bin/nodedc-ops-codex.mjs", + "nodedc-ops-codex": "./bin/nodedc-ops-codex.mjs" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "bin/", + "README.md" + ], + "keywords": [ + "nodedc", + "ops", + "tasker", + "codex", + "mcp" + ], + "license": "UNLICENSED", + "private": false, + "publishConfig": { + "access": "public" + } +} diff --git a/src/config.ts b/src/config.ts index aa25fe7..dad9f7e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { z } from "zod"; const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional()); +const npmPackageSpec = z.string().min(1).max(214).regex(/^\S+$/, "must not contain whitespace"); const configSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), @@ -9,6 +10,8 @@ const configSchema = z.object({ LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"), NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"), NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(), + NODEDC_OPS_CODEX_INSTALL_CHANNEL: z.enum(["tarball", "registry"]).default("tarball"), + NODEDC_OPS_CODEX_NPM_SPEC: npmPackageSpec.default("@nodedc/ops-codex"), NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200), NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"), NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"), diff --git a/src/mcp/tool-runtime.ts b/src/mcp/tool-runtime.ts index aa7ac6e..cfe1ae5 100644 --- a/src/mcp/tool-runtime.ts +++ b/src/mcp/tool-runtime.ts @@ -118,32 +118,36 @@ const structuredBlocksJsonSchema = { export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ { name: "tasker_get_agent_instructions", - title: "Get Agent Instructions", - description: "Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.", + title: "NODE.DC Ops: Get Agent Instructions", + description: + "Direct NODE.DC Ops MCP tool. Return effective Tasker card-writing rules, grants, scopes, and mode expectations. Use this before any Ops card read/write workflow.", requiredScopes: ["workspace:read"], inputSchema: emptyInputSchema, annotations: { readOnlyHint: true }, }, { name: "tasker_list_projects", - title: "List Granted Projects", - description: "List Tasker projects granted to the current agent.", + title: "NODE.DC Ops: List Granted Projects", + description: + "Direct NODE.DC Ops MCP tool. List Tasker/Ops projects granted to the current Codex agent. Use this instead of Codex Apps workspace widgets.", requiredScopes: ["project:read"], inputSchema: emptyInputSchema, annotations: { readOnlyHint: true }, }, { name: "tasker_get_project_context", - title: "Get Project Context", - description: "Return states, labels, members, and card-writing context for one granted project.", + title: "NODE.DC Ops: Get Project Context", + description: + "Direct NODE.DC Ops MCP tool. Return states, labels, members, and card-writing context for one granted Tasker/Ops project.", requiredScopes: ["project:read"], inputSchema: projectInputSchema, annotations: { readOnlyHint: true }, }, { name: "tasker_search_issues", - title: "Search Issues", - description: "Search work items inside one granted Tasker project.", + title: "NODE.DC Ops: Search Cards", + description: + "Direct NODE.DC Ops MCP tool. Search Tasker/Ops cards and work items inside one granted project. Use this for 'show cards', 'find cards', and project context requests.", requiredScopes: ["issue:read"], inputSchema: { ...projectInputSchema, @@ -156,8 +160,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_create_issue", - title: "Create Issue", - description: "Create a Tasker card with optional NODE.DC structured text/checker blocks.", + title: "NODE.DC Ops: Create Card", + description: + "Direct NODE.DC Ops MCP write tool. Create a Tasker/Ops card with optional NODE.DC structured text/checker blocks.", requiredScopes: ["issue:create"], inputSchema: { type: "object", @@ -177,8 +182,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_update_issue", - title: "Update Issue", - description: "Patch allowed issue fields without delete, archive, or project transfer.", + title: "NODE.DC Ops: Update Card", + description: + "Direct NODE.DC Ops MCP write tool. Patch allowed Tasker/Ops card fields without delete, archive, or project transfer.", requiredScopes: ["issue:update"], inputSchema: { type: "object", @@ -199,8 +205,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_update_structured_blocks", - title: "Update Structured Blocks", - description: "Replace NODE.DC structured text/checker blocks in an issue detail layout.", + title: "NODE.DC Ops: Update Structured Blocks", + description: + "Direct NODE.DC Ops MCP write tool. Replace NODE.DC structured text/checker blocks in a Tasker/Ops card detail layout.", requiredScopes: ["issue:update", "issue:structured_blocks:write"], inputSchema: { ...projectAndIssueInputSchema, @@ -215,8 +222,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_move_issue", - title: "Move Issue", - description: "Move an issue to an existing state in the same granted project.", + title: "NODE.DC Ops: Move Card", + description: "Direct NODE.DC Ops MCP write tool. Move a Tasker/Ops card to an existing state in the same granted project.", requiredScopes: ["issue:move"], inputSchema: { ...projectAndIssueInputSchema, @@ -231,8 +238,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_append_comment", - title: "Append Comment", - description: "Append a comment to a granted issue.", + title: "NODE.DC Ops: Append Comment", + description: "Direct NODE.DC Ops MCP write tool. Append a comment to a granted Tasker/Ops card.", requiredScopes: ["issue:comment"], inputSchema: { ...projectAndIssueInputSchema, @@ -247,8 +254,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_ensure_labels", - title: "Ensure Project Labels", - description: "Create missing labels in a granted project and return label ids for subsequent issue labeling.", + title: "NODE.DC Ops: Ensure Project Labels", + description: + "Direct NODE.DC Ops MCP write tool. Create missing labels in a granted project and return label ids for subsequent card labeling.", requiredScopes: ["issue:label"], inputSchema: { ...projectInputSchema, @@ -279,8 +287,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_set_issue_labels", - title: "Set Issue Labels", - description: "Replace issue labels with existing or ensured labels from the granted project.", + title: "NODE.DC Ops: Set Card Labels", + description: "Direct NODE.DC Ops MCP write tool. Replace card labels with existing or ensured labels from the granted project.", requiredScopes: ["issue:label"], inputSchema: { ...projectAndIssueInputSchema, @@ -295,8 +303,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, { name: "tasker_assign_issue", - title: "Assign Issue", - description: "Replace issue assignees with existing members of the granted project.", + title: "NODE.DC Ops: Assign Card", + description: "Direct NODE.DC Ops MCP write tool. Replace card assignees with existing members of the granted project.", requiredScopes: ["issue:assign"], inputSchema: { ...projectAndIssueInputSchema, diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index 483e4a9..da6f36f 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto"; import type { Pool } from "pg"; import { allowedAgentScopes, type AgentScope } from "../domain/scopes.js"; -import { hashAgentToken } from "../security/tokens.js"; +import { hashAgentSetupCode, hashAgentToken } from "../security/agent-access.js"; const allowedScopeSet = new Set(allowedAgentScopes); export type AgentStatus = "active" | "disabled" | "revoked"; export type AgentGrantMode = "voluntary" | "reporting"; +export type AgentSetupCodeStatus = "active" | "used" | "expired" | "revoked"; export type AgentTokenStatus = "active" | "revoked" | "expired"; export type AgentTokenGrantScope = "agent" | "token"; @@ -54,6 +55,19 @@ export type AgentSessionRecord = { grantSource: AgentTokenGrantScope; }; +export type AgentSetupCodeRecord = { + id: string; + agentId: string; + codeSuffix: string | null; + status: AgentSetupCodeStatus; + tokenName: string; + createdByUserId: string | null; + expiresAt: string; + usedAt: string | null; + usedTokenId: string | null; + createdAt: string; +}; + type AgentRow = { id: string; owner_user_id: string; @@ -89,6 +103,20 @@ type TokenRow = { created_at: Date; }; +type SetupCodeRow = { + id: string; + agent_id: string; + code_hash: string; + code_suffix: string | null; + status: AgentSetupCodeStatus; + token_name: string; + created_by_user_id: string | null; + expires_at: Date; + used_at: Date | null; + used_token_id: string | null; + created_at: Date; +}; + type SessionRow = AgentRow & { token_id: string; token_name: string; @@ -173,6 +201,13 @@ export type CreateTokenInput = { tokenGrants?: CreateTokenGrantInput[]; }; +export type CreateSetupCodeInput = { + code: string; + createdByUserId?: string | null; + expiresAt: string; + tokenName: string; +}; + export type CreateTokenGrantInput = { workspaceSlug: string; projectId?: string | null; @@ -198,6 +233,18 @@ export type IdempotencyClaim = status: "conflict"; }; +export type RedeemSetupCodeResult = + | { + status: "redeemed"; + agent: AgentRecord; + token: AgentTokenRecord; + grants: AgentGrantRecord[]; + setupCode: AgentSetupCodeRecord; + } + | { + status: "invalid" | "expired" | "used" | "revoked"; + }; + export class AgentsRepository { constructor(private readonly pool: Pool) {} @@ -544,6 +591,168 @@ export class AgentsRepository { return tokenRecord; } + async createSetupCode(agentId: string, input: CreateSetupCodeInput): Promise { + const result = await this.pool.query( + ` + INSERT INTO agent_setup_codes(agent_id, code_hash, code_suffix, token_name, created_by_user_id, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, + [ + agentId, + hashAgentSetupCode(input.code), + input.code.slice(-8), + input.tokenName, + input.createdByUserId ?? null, + input.expiresAt, + ] + ); + const setupCode = mapSetupCode(result.rows[0]); + + await this.createAuditEvent(agentId, "agent.setup_code.created", input.createdByUserId ?? undefined, { + setupCodeId: setupCode.id, + codeSuffix: setupCode.codeSuffix, + expiresAt: setupCode.expiresAt, + tokenName: setupCode.tokenName, + }); + + return setupCode; + } + + async redeemSetupCode(code: string, token: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + const setupCodeResult = await client.query( + ` + SELECT * + FROM agent_setup_codes + WHERE code_hash = $1 + FOR UPDATE + `, + [hashAgentSetupCode(code)] + ); + const setupCodeRow = setupCodeResult.rows[0]; + + if (!setupCodeRow) { + await client.query("COMMIT"); + return { status: "invalid" }; + } + + if (setupCodeRow.status === "used" || setupCodeRow.used_at) { + await client.query("COMMIT"); + return { status: "used" }; + } + + if (setupCodeRow.status === "revoked") { + await client.query("COMMIT"); + return { status: "revoked" }; + } + + if (setupCodeRow.status !== "active" || setupCodeRow.expires_at <= new Date()) { + await client.query( + ` + UPDATE agent_setup_codes + SET status = 'expired' + WHERE id = $1 AND status = 'active' + `, + [setupCodeRow.id] + ); + await client.query( + ` + INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata) + VALUES ($1, $2, $3, $4) + `, + [ + setupCodeRow.agent_id, + "agent.setup_code.expired", + setupCodeRow.created_by_user_id, + { + setupCodeId: setupCodeRow.id, + codeSuffix: setupCodeRow.code_suffix, + }, + ] + ); + await client.query("COMMIT"); + return { status: "expired" }; + } + + const agentResult = await client.query( + ` + SELECT * + FROM agents + WHERE id = $1 AND status = 'active' + FOR UPDATE + `, + [setupCodeRow.agent_id] + ); + const agentRow = agentResult.rows[0]; + + if (!agentRow) { + await client.query("COMMIT"); + return { status: "revoked" }; + } + + const tokenResult = await client.query( + ` + INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at, grant_scope) + VALUES ($1, $2, $3, $4, NULL, 'agent') + RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at + `, + [setupCodeRow.agent_id, hashAgentToken(token), token.slice(-8), setupCodeRow.token_name] + ); + const tokenRow = tokenResult.rows[0]; + + const redeemedSetupCodeResult = await client.query( + ` + UPDATE agent_setup_codes + SET status = 'used', used_at = now(), used_token_id = $2 + WHERE id = $1 + RETURNING * + `, + [setupCodeRow.id, tokenRow.id] + ); + + const grantResult = await client.query( + "SELECT * FROM agent_grants WHERE agent_id = $1 ORDER BY created_at DESC", + [setupCodeRow.agent_id] + ); + + await client.query( + ` + INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata) + VALUES ($1, $2, $3, $4) + `, + [ + setupCodeRow.agent_id, + "agent.setup_code.redeemed", + setupCodeRow.created_by_user_id, + { + setupCodeId: setupCodeRow.id, + codeSuffix: setupCodeRow.code_suffix, + tokenId: tokenRow.id, + tokenName: tokenRow.name, + }, + ] + ); + + await client.query("COMMIT"); + return { + status: "redeemed", + agent: mapAgent(agentRow), + token: mapToken(tokenRow), + grants: grantResult.rows.map(mapGrant), + setupCode: mapSetupCode(redeemedSetupCodeResult.rows[0]), + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async listTokens(agentId: string): Promise { const result = await this.pool.query( ` @@ -823,6 +1032,21 @@ function mapToken(row: TokenRow): AgentTokenRecord { }; } +function mapSetupCode(row: SetupCodeRow): AgentSetupCodeRecord { + return { + id: row.id, + agentId: row.agent_id, + codeSuffix: row.code_suffix, + status: row.status, + tokenName: row.token_name, + createdByUserId: row.created_by_user_id, + expiresAt: row.expires_at.toISOString(), + usedAt: row.used_at?.toISOString() ?? null, + usedTokenId: row.used_token_id, + createdAt: row.created_at.toISOString(), + }; +} + function mapTokenGrant(row: TokenGrantRow): AgentGrantRecord { return { id: row.id, diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 8dcf527..ad16399 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -8,6 +8,7 @@ import { mcpToolDefinitions } from "../mcp/tools.js"; import type { AgentGrantRecord, AgentRecord, + AgentSetupCodeRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository, @@ -15,10 +16,12 @@ import type { } from "../repositories/agents.js"; import { parseBearerToken, UnauthorizedError } from "../security/bearer.js"; import { requireInternalAccess } from "../security/internal.js"; -import { generateAgentToken } from "../security/tokens.js"; +import { generateAgentSetupCode, generateAgentToken } from "../security/agent-access.js"; type AgentRouteDeps = { agentsRepository: AgentsRepository | null; + codexInstallChannel: "registry" | "tarball"; + codexNpmSpec: string; publicUrl: string; internalAccessToken?: string; aiWorkspaceRunTokenTtlSeconds: number; @@ -28,6 +31,11 @@ const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000; const OPS_APP_ID = "ops"; const OPS_APP_TITLE = "NODE.DC Ops"; const OPS_MCP_SERVER_NAME = "nodedc_ops_agent"; +const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent"; +const CODEX_NPM_PACKAGE_VERSION = "0.1.2"; +const DEFAULT_OPS_AGENT_GATEWAY = "https://ops-agents.nodedc.ru"; +const DEFAULT_SETUP_CODE_TTL_SECONDS = 10 * 60; +const MAX_SETUP_CODE_TTL_SECONDS = 24 * 60 * 60; function isAllowedAvatarUrl(value: string): boolean { if (value.startsWith("data:image/")) { @@ -125,6 +133,15 @@ const createTokenBodySchema = z.object({ expires_at: z.string().datetime().nullish(), }); +const createSetupCodeBodySchema = z.object({ + expires_in_seconds: z.number().int().min(60).max(MAX_SETUP_CODE_TTL_SECONDS).default(DEFAULT_SETUP_CODE_TTL_SECONDS), + token_name: z.string().min(1).max(120).default("Local Codex token"), +}); + +const redeemSetupCodeBodySchema = z.object({ + setup_code: z.string().min(16).max(160), +}); + const actorBodySchema = z.object({ actor_user_id: z.string().min(1).optional(), }); @@ -176,6 +193,31 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }; }); + app.post("/api/v1/setup-codes/redeem", async (request, reply) => { + const repository = requireRepository(deps); + const body = redeemSetupCodeBodySchema.parse(request.body ?? {}); + const token = generateAgentToken(); + const result = await repository.redeemSetupCode(body.setup_code, token); + + if (result.status !== "redeemed") { + return reply.status(400).send({ + ok: false, + error: "setup_code_not_redeemable", + status: result.status, + message: "Setup code is invalid, expired, used, or revoked.", + }); + } + + return { + ok: true, + token, + token_record: serializeToken(result.token), + agent: serializeAgent(result.agent), + setup_code: serializeSetupCode(result.setupCode), + setup: buildSetupPacketForAgent(result.agent, result.grants, deps.publicUrl), + }; + }); + app.post("/api/internal/v1/ai-workspace/entitlements", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); @@ -744,6 +786,34 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); }); + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/setup-codes", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = createSetupCodeBodySchema.parse(request.body ?? {}); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const setupCode = generateAgentSetupCode(); + const expiresAt = new Date(Date.now() + body.expires_in_seconds * 1000).toISOString(); + const setupCodeRecord = await repository.createSetupCode(agentId, { + code: setupCode, + createdByUserId: ownerUserId, + expiresAt, + tokenName: body.token_name, + }); + + return reply.status(201).send({ + ok: true, + setup_code: setupCode, + setup_code_record: serializeSetupCode(setupCodeRecord), + install: buildCodexInstallerPacket(setupCode, deps), + }); + }); + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); @@ -1076,6 +1146,77 @@ function serializeToken(token: AgentTokenRecord): Record { }; } +function serializeSetupCode(setupCode: AgentSetupCodeRecord): Record { + return { + id: setupCode.id, + agent_id: setupCode.agentId, + code_suffix: setupCode.codeSuffix, + status: setupCode.status, + token_name: setupCode.tokenName, + created_by_user_id: setupCode.createdByUserId, + expires_at: setupCode.expiresAt, + used_at: setupCode.usedAt, + used_token_id: setupCode.usedTokenId, + created_at: setupCode.createdAt, + }; +} + +function buildCodexInstallerPacket(setupCode: string, deps: AgentRouteDeps): Record { + const gatewayUrl = new URL(deps.publicUrl).origin; + const packageUrl = new URL("/install/nodedc-ops-codex-setup.tgz", gatewayUrl); + packageUrl.searchParams.set("v", CODEX_NPM_PACKAGE_VERSION); + packageUrl.searchParams.set("s", setupCode.slice(-8)); + const packageUrlString = packageUrl.toString(); + const installerUrl = new URL("/install/codex.py", gatewayUrl).toString(); + const registryCommand = [ + "npx", + "--yes", + shellWord(deps.codexNpmSpec), + "setup", + shellWord(setupCode), + ...(gatewayUrl === DEFAULT_OPS_AGENT_GATEWAY ? [] : ["--gateway", shellWord(gatewayUrl)]), + ].join(" "); + const fallbackCommand = [ + "npm", + "exec", + "--yes", + `--package=${shellWord(packageUrlString)}`, + "--", + "ops-codex", + "setup", + shellWord(setupCode), + "--gateway", + shellWord(gatewayUrl), + ].join(" "); + const command = deps.codexInstallChannel === "registry" ? registryCommand : fallbackCommand; + + return { + platform: "macos_linux", + installer_kind: deps.codexInstallChannel === "registry" ? "npm_registry" : "npm_tarball", + installer_url: packageUrlString, + npm_spec: deps.codexNpmSpec, + package_url: packageUrlString, + legacy_python_installer_url: installerUrl, + registry_command: registryCommand, + fallback_command: fallbackCommand, + mcp_server_name: CODEX_MCP_SERVER_NAME, + skill_name: "ops-context", + command, + }; +} + +function shellWord(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value; + } + + return shellSingleQuote(value); +} + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + function serializeAgentSession(session: AgentSessionRecord): Record { return { agent: serializeAgent(session.agent), @@ -1093,7 +1234,7 @@ function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Recor return { mcp_server: { - name: "nodedc_tasker", + name: CODEX_MCP_SERVER_NAME, transport: "streamable_http_json_rpc", url: endpoint, headers: { @@ -1104,7 +1245,7 @@ function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Recor }, codex_config_template: { mcp_servers: { - nodedc_tasker: { + [CODEX_MCP_SERVER_NAME]: { url: endpoint, enabled: true, required: false, diff --git a/src/routes/install.ts b/src/routes/install.ts new file mode 100644 index 0000000..53f82b4 --- /dev/null +++ b/src/routes/install.ts @@ -0,0 +1,330 @@ +import { readFile } from "node:fs/promises"; +import { gzipSync } from "node:zlib"; + +import type { FastifyInstance } from "fastify"; + +type InstallerRouteDeps = { + publicUrl: string; +}; + +export async function registerInstallerRoutes(app: FastifyInstance, deps: InstallerRouteDeps): Promise { + app.get("/install/codex.py", async (_request, reply) => + reply + .type("text/x-python; charset=utf-8") + .header("Cache-Control", "no-store") + .send(renderCodexPythonInstaller(deps.publicUrl)) + ); + + app.get("/install/nodedc-ops-codex-setup.tgz", async (_request, reply) => + reply + .type("application/gzip") + .header("Cache-Control", "no-store") + .header("Content-Disposition", 'attachment; filename="nodedc-ops-codex-setup.tgz"') + .send(await renderCodexNpmPackageArchive()) + ); +} + +async function renderCodexNpmPackageArchive(): Promise { + const files = await Promise.all([ + readPackageAsset("package/package.json", "../assets/codex-npm/package.json", 0o644), + readPackageAsset("package/README.md", "../assets/codex-npm/README.md", 0o644), + readPackageAsset("package/bin/nodedc-ops-codex.mjs", "../assets/codex-npm/bin/nodedc-ops-codex.mjs", 0o755), + ]); + + return gzipSync(createTar(files), { level: 9 }); +} + +async function readPackageAsset(name: string, assetPath: string, mode: number): Promise { + return { + name, + content: await readFile(new URL(assetPath, import.meta.url)), + mode, + }; +} + +type TarEntry = { + name: string; + content: Buffer; + mode: number; +}; + +function createTar(entries: TarEntry[]): Buffer { + const chunks: Buffer[] = []; + for (const entry of entries) { + chunks.push(createTarHeader(entry)); + chunks.push(entry.content); + const remainder = entry.content.length % 512; + if (remainder) { + chunks.push(Buffer.alloc(512 - remainder)); + } + } + chunks.push(Buffer.alloc(1024)); + return Buffer.concat(chunks); +} + +function createTarHeader(entry: TarEntry): Buffer { + if (Buffer.byteLength(entry.name) > 100) { + throw new Error(`npm package tar path is too long: ${entry.name}`); + } + + const header = Buffer.alloc(512, 0); + writeTarString(header, 0, 100, entry.name); + writeTarOctal(header, 100, 8, entry.mode); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, entry.content.length); + writeTarOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + header[156] = "0".charCodeAt(0); + writeTarString(header, 257, 6, "ustar"); + writeTarString(header, 263, 2, "00"); + + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeTarString(header, 148, 8, checksum.toString(8).padStart(6, "0") + "\0 "); + return header; +} + +function writeTarString(header: Buffer, offset: number, length: number, value: string): void { + header.write(value.slice(0, length), offset, length, "utf8"); +} + +function writeTarOctal(header: Buffer, offset: number, length: number, value: number): void { + const text = value.toString(8).padStart(length - 1, "0") + "\0"; + writeTarString(header, offset, length, text); +} + +function renderCodexPythonInstaller(publicUrl: string): string { + const defaultGatewayUrl = new URL(publicUrl).origin; + + return `#!/usr/bin/env python3 +import argparse +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.request + +DEFAULT_GATEWAY = ${JSON.stringify(defaultGatewayUrl)} +SERVER_NAME = "nodedc-ops-agent" +SKILL_NAME = "ops-context" +MCP_PROTOCOL_VERSION = "2025-06-18" + + +def main(): + args = parse_args() + gateway = args.gateway.rstrip("/") + redeem_payload = redeem_setup_code(gateway, args.setup_code) + token = redeem_payload.get("token") + setup = redeem_payload.get("setup") or {} + mcp_server = setup.get("mcp_server") or {} + endpoint = str(mcp_server.get("url") or gateway + "/mcp") + agents_md = setup.get("agents_md") or default_skill_body(endpoint) + + codex_home = resolve_codex_home(args.codex_home) + config_path = codex_home / "config.toml" + skill_path = codex_home / "skills" / SKILL_NAME / "SKILL.md" + + write_codex_config(config_path, endpoint, token) + write_skill(skill_path, agents_md, endpoint) + tool_count = smoke_tools_list(endpoint, token) + + print("NODE.DC Ops Codex setup complete.") + print("Config:", str(config_path)) + print("Skill:", str(skill_path)) + print("Gateway MCP smoke tools:", tool_count) + print("Restart Codex Desktop completely before testing. A new chat is not enough if the Desktop process already loaded MCP config.") + print("In the next session, tool discovery must expose tasker_list_projects. If it does not, Codex has not loaded the NODE.DC Ops MCP server.") + + +def parse_args(): + raw_args = sys.argv[1:] + if raw_args and raw_args[0] == "--": + raw_args = raw_args[1:] + + parser = argparse.ArgumentParser(description="Install NODE.DC Ops MCP access into local Codex.") + parser.add_argument("--setup-code", required=True) + parser.add_argument("--gateway", default=os.environ.get("NODEDC_OPS_AGENT_GATEWAY", DEFAULT_GATEWAY)) + parser.add_argument("--codex-home", default=os.environ.get("CODEX_HOME")) + return parser.parse_args(raw_args) + + +def redeem_setup_code(gateway, setup_code): + payload = json.dumps({"setup_code": setup_code}).encode("utf-8") + request = urllib.request.Request( + gateway + "/api/v1/setup-codes/redeem", + data=payload, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + data = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", "replace") + raise SystemExit("Setup code redeem failed: HTTP " + str(error.code) + " " + body) + except urllib.error.URLError as error: + raise SystemExit("Setup code redeem failed: " + str(error.reason)) + + if not data.get("ok") or not data.get("token"): + raise SystemExit("Setup code redeem failed: " + json.dumps(data, ensure_ascii=False)) + + return data + + +def resolve_codex_home(codex_home_arg): + if codex_home_arg: + return pathlib.Path(codex_home_arg).expanduser() + return pathlib.Path.home() / ".codex" + + +def write_codex_config(config_path, endpoint, token): + config_path.parent.mkdir(parents=True, exist_ok=True) + original = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + backup_path = None + if config_path.exists(): + backup_path = config_path.with_name(config_path.name + ".nodedc-bak") + backup_path.write_text(original, encoding="utf-8") + + next_text = strip_server_sections(original).rstrip() + if next_text: + next_text += "\\n\\n" + next_text += build_mcp_config_block(endpoint, token) + next_text += "\\n" + config_path.write_text(next_text, encoding="utf-8") + + if backup_path: + print("Backup:", str(backup_path)) + + +def strip_server_sections(text): + target = "mcp_servers." + SERVER_NAME + kept = [] + skip = False + for line in text.splitlines(): + match = re.match(r"\\s*\\[([^\\]]+)\\]\\s*(?:#.*)?$", line) + if match: + section = match.group(1).strip() + skip = section == target or section.startswith(target + ".") + if not skip: + kept.append(line) + return "\\n".join(kept) + + +def build_mcp_config_block(endpoint, token): + return "\\n".join( + [ + "[mcp_servers." + SERVER_NAME + "]", + "url = " + toml_string(endpoint), + "enabled = true", + "required = false", + "startup_timeout_sec = 20", + "tool_timeout_sec = 60", + "", + "[mcp_servers." + SERVER_NAME + ".http_headers]", + "Authorization = " + toml_string("Bearer " + token), + "Accept = " + toml_string("application/json"), + toml_string("MCP-Protocol-Version") + " = " + toml_string(MCP_PROTOCOL_VERSION), + ] + ) + + +def toml_string(value): + return json.dumps(str(value), ensure_ascii=False) + + +def write_skill(skill_path, agents_md, endpoint): + skill_path.parent.mkdir(parents=True, exist_ok=True) + skill_path.write_text(build_skill_body(agents_md, endpoint), encoding="utf-8") + + +def build_skill_body(agents_md, endpoint): + return "\\n".join( + [ + "---", + "name: ops-context", + "description: Use when working with NODE.DC Ops/Tasker cards, project cards, checkers, labels, comments, or when the user asks for ops-context. Always use the direct nodedc-ops-agent tasker_* MCP tools and never the old codex_apps OPS widgets.", + "---", + "", + "# ops-context", + "", + "Use this skill when the user asks to work with NODE.DC Ops, Tasker cards, project cards, checkers, labels, comments, or asks for ops-context.", + "", + "## Hard Rules", + "", + "- Treat Ops MCP as the source of truth for cards and project context.", + "- Use only the direct nodedc-ops-agent MCP tools for Ops cards. The expected tool names are tasker_get_agent_instructions, tasker_list_projects, tasker_get_project_context, tasker_search_issues, and the other tasker_* tools listed below.", + "- Do not use Codex Apps workspace widgets for Ops: never call tools from codex_apps servers named nodedc_ops_readonly, nodedc_ops_gateway_readonly, ops-readonly, or ops_gateway_readonly. They are known-broken for Tasker cards and waste context.", + "- Do not open current workspace widgets, CodexPro cards, .ai-bridge handoff files, local thread lists, or broad filesystem searches to answer Ops card requests.", + "- MCP tools can be lazy-loaded. If mcp__nodedc_ops_agent.tasker_list_projects is not already visible in the active tools, do not answer yet.", + "- First call the available tool discovery/search tool, normally tool_search.tool_search_tool, with query exactly: tasker_list_projects nodedc-ops-agent NODE.DC Ops MCP", + "- After discovery returns NODE.DC Ops tools, immediately call mcp__nodedc_ops_agent.tasker_get_agent_instructions, then mcp__nodedc_ops_agent.tasker_list_projects, then mcp__nodedc_ops_agent.tasker_get_project_context for the target project.", + "- Resolve workspace/project from tasker_list_projects and granted project context. Do not guess a workspace slug from user wording.", + "- Stop only if no discovery/search tool is available, or discovery returns no mcp__nodedc_ops_agent tasker_* tools. Then tell the user: 'NODE.DC Ops MCP is installed in config.toml, but this Codex session did not expose the nodedc-ops-agent tools after tool discovery.' Do not fall back to filesystem search, config.toml parsing, curl, or Codex Apps widgets unless the user explicitly asks to debug installation.", + "- Never print Authorization headers, setup codes, or agent tokens.", + "- Every write must use the official Tasker MCP write tool and a fresh idempotency_key.", + "- Do not delete or archive cards, comments, labels, projects, states, members, or workspaces.", + "", + "## Installed Endpoint", + "", + endpoint, + "", + "## Gateway Rules Snapshot", + "", + agents_md.strip(), + "", + ] + ) + + +def default_skill_body(endpoint): + return "\\n".join( + [ + "# NODE.DC Ops Agent Rules", + "", + "MCP endpoint: " + endpoint, + "", + "Call tasker_get_agent_instructions, tasker_list_projects, and tasker_get_project_context before writing Tasker cards.", + ] + ) + + +def smoke_tools_list(endpoint, token): + payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode("utf-8") + request = urllib.request.Request( + endpoint, + data=payload, + headers={ + "Authorization": "Bearer " + token, + "Accept": "application/json", + "Content-Type": "application/json", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + data = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", "replace") + raise SystemExit("MCP smoke check failed: HTTP " + str(error.code) + " " + body) + except urllib.error.URLError as error: + raise SystemExit("MCP smoke check failed: " + str(error.reason)) + + if data.get("error"): + raise SystemExit("MCP smoke check failed: " + json.dumps(data.get("error"), ensure_ascii=False)) + + tools = ((data.get("result") or {}).get("tools") or []) + if not tools: + raise SystemExit("MCP smoke check failed: tools/list returned no tools.") + return len(tools) + + +if __name__ == "__main__": + main() +`; +} diff --git a/src/security/agent-access.ts b/src/security/agent-access.ts new file mode 100644 index 0000000..2bf4bca --- /dev/null +++ b/src/security/agent-access.ts @@ -0,0 +1,20 @@ +import { createHash, randomBytes } from "node:crypto"; + +const TOKEN_PREFIX = "ndcag"; +const SETUP_CODE_PREFIX = "ndcsetup"; + +export function generateAgentToken(): string { + return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`; +} + +export function hashAgentToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function generateAgentSetupCode(): string { + return `${SETUP_CODE_PREFIX}_${randomBytes(32).toString("base64url")}`; +} + +export function hashAgentSetupCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +} diff --git a/src/security/tokens.ts b/src/security/tokens.ts index 508ce40..2bf4bca 100644 --- a/src/security/tokens.ts +++ b/src/security/tokens.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; const TOKEN_PREFIX = "ndcag"; +const SETUP_CODE_PREFIX = "ndcsetup"; export function generateAgentToken(): string { return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`; @@ -9,3 +10,11 @@ export function generateAgentToken(): string { export function hashAgentToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } + +export function generateAgentSetupCode(): string { + return `${SETUP_CODE_PREFIX}_${randomBytes(32).toString("base64url")}`; +} + +export function hashAgentSetupCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +}