feat: add Ops Codex npm setup flow

This commit is contained in:
DCCONSTRUCTIONS 2026-06-24 20:12:09 +03:00
parent 3acf758743
commit 5e79a53e13
16 changed files with 1596 additions and 28 deletions

View File

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

View File

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

View File

@ -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}

View File

@ -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}

View File

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

View File

@ -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<FastifyInstance> {
});
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,

View File

@ -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 <setup-code>
ops-codex status
ops-codex doctor
```
`doctor` checks the local config, installed skill, and MCP connectivity.
## Options
```sh
--gateway <url> Ops Agent Gateway URL. Defaults to https://ops-agents.nodedc.ru.
--codex-home <path> Codex home directory. Overrides auto-discovery.
```
For an unusual portable Codex layout, run setup from the portable app folder or pass `--codex-home <path>` explicitly.

View File

@ -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 <ndcsetup_...>");
}
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 <path>]
`);
return;
}
if (command === "doctor") {
console.log(`Check local NODE.DC Ops Codex install and MCP connectivity.
Usage:
ops-codex doctor [--codex-home <path>]
`);
return;
}
console.log(`Install NODE.DC Ops MCP access into local Codex.
Usage:
ops-codex setup <setup-code> [--gateway <url>] [--codex-home <path>]
ops-codex setup --code <setup-code> [--gateway <url>] [--codex-home <path>]
ops-codex status [--codex-home <path>]
ops-codex doctor [--codex-home <path>]
Registry form after publish:
npx --yes @nodedc/ops-codex setup <setup-code>
Self-host fallback:
npm exec --yes --package=<gateway>/install/nodedc-ops-codex-setup.tgz -- ops-codex setup <setup-code>
Options:
--code, --setup-code <code> One-time setup code generated by NODE.DC Ops.
--gateway <url> Ops Agent Gateway URL. Defaults to ${DEFAULT_GATEWAY}.
--codex-home <path> 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 <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);
});

View File

@ -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"
}
}

View File

@ -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"),

View File

@ -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,

View File

@ -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<string>(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<AgentSetupCodeRecord> {
const result = await this.pool.query<SetupCodeRow>(
`
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<RedeemSetupCodeResult> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const setupCodeResult = await client.query<SetupCodeRow>(
`
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<AgentRow>(
`
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<TokenRow>(
`
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<SetupCodeRow>(
`
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<GrantRow>(
"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<AgentTokenRecord[]> {
const result = await this.pool.query<TokenRow>(
`
@ -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,

View File

@ -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<string, unknown> {
};
}
function serializeSetupCode(setupCode: AgentSetupCodeRecord): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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,

330
src/routes/install.ts Normal file
View File

@ -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<void> {
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<Buffer> {
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<TarEntry> {
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()
`;
}

View File

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

View File

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