Compare commits
3 Commits
CROSSASSIS
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
b30f1c3abe | |
|
|
5e79a53e13 | |
|
|
3acf758743 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ 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}
|
||||
NODEDC_ENGINE_INTERNAL_URL: ${NODEDC_ENGINE_INTERNAL_URL:-http://172.22.0.222:3001}
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -6,7 +6,9 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
|
|||
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
|
||||
import { AgentsRepository } from "./repositories/agents.js";
|
||||
import { registerAgentRoutes } from "./routes/agents.js";
|
||||
import { registerEngineGatewayRoutes } from "./routes/engine.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,13 +129,20 @@ 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,
|
||||
});
|
||||
await registerEngineGatewayRoutes(app, {
|
||||
engineInternalUrl: config.NODEDC_ENGINE_INTERNAL_URL,
|
||||
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
|
||||
});
|
||||
await registerToolRoutes(app, { agentsRepository, taskerClient });
|
||||
await registerMcpRoutes(app, { agentsRepository, taskerClient });
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,9 +10,12 @@ 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"),
|
||||
NODEDC_ENGINE_INTERNAL_URL: z.string().url().default("http://172.22.0.222:3001"),
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
|
||||
DATABASE_URL: optionalUrl,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
type EngineGatewayRouteDeps = {
|
||||
engineInternalUrl: string;
|
||||
publicUrl: string;
|
||||
};
|
||||
|
||||
const RESPONSE_HEADERS = [
|
||||
"cache-control",
|
||||
"content-disposition",
|
||||
"mcp-protocol-version",
|
||||
"mcp-session-id",
|
||||
"vary",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fixed Engine namespace inside the public Agent Gateway.
|
||||
*
|
||||
* This is not a generic reverse proxy. Engine keeps ownership of its setup
|
||||
* codes, device tokens, L1 -> L2 grants, and tool authorization; the public
|
||||
* gateway only forwards the explicitly registered bootstrap and MCP routes.
|
||||
*/
|
||||
export async function registerEngineGatewayRoutes(app: FastifyInstance, deps: EngineGatewayRouteDeps): Promise<void> {
|
||||
app.get("/engine/healthz", async (request, reply) =>
|
||||
proxyEngine(request, reply, deps, "/health", { binary: false })
|
||||
);
|
||||
app.get("/engine/install/nodedc-engine-codex-agent-setup.tgz", async (request, reply) =>
|
||||
proxyEngine(request, reply, deps, "/api/engine-agent-api/install/engine-codex-agent.tgz", { binary: true })
|
||||
);
|
||||
app.post("/engine/api/v1/setup-codes/redeem", async (request, reply) =>
|
||||
proxyEngine(request, reply, deps, "/api/engine-agent-api/setup/redeem")
|
||||
);
|
||||
app.get("/engine/doctor", async (request, reply) =>
|
||||
proxyEngine(request, reply, deps, "/api/engine-agent-api/doctor")
|
||||
);
|
||||
app.get("/engine/mcp", async (request, reply) =>
|
||||
proxyEngine(request, reply, deps, "/api/engine-agent-mcp")
|
||||
);
|
||||
app.post("/engine/mcp", async (request, reply) =>
|
||||
proxyEngine(request, reply, deps, "/api/engine-agent-mcp")
|
||||
);
|
||||
}
|
||||
|
||||
async function proxyEngine(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: EngineGatewayRouteDeps,
|
||||
pathname: string,
|
||||
{ binary = false }: { binary?: boolean } = {}
|
||||
): Promise<FastifyReply> {
|
||||
const method = request.method.toUpperCase();
|
||||
const hasBody = method !== "GET" && method !== "HEAD";
|
||||
const publicUrl = new URL(deps.publicUrl);
|
||||
const headers = new Headers();
|
||||
headers.set("Accept", readHeader(request.headers.accept) || (binary ? "application/octet-stream" : "application/json"));
|
||||
headers.set("X-Forwarded-Host", publicUrl.host);
|
||||
headers.set("X-Forwarded-Proto", publicUrl.protocol.replace(/:$/, "") || "https");
|
||||
|
||||
for (const name of ["authorization", "content-type", "mcp-protocol-version", "mcp-session-id", "last-event-id"] as const) {
|
||||
const value = readHeader(request.headers[name]);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(new URL(pathname, deps.engineInternalUrl), {
|
||||
method,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
body: hasBody ? JSON.stringify(request.body ?? {}) : undefined,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
return reply.status(502).send({ ok: false, error: "engine_gateway_unavailable" });
|
||||
}
|
||||
|
||||
reply.status(upstream.status);
|
||||
reply.type(upstream.headers.get("content-type") || (binary ? "application/octet-stream" : "application/json; charset=utf-8"));
|
||||
for (const header of RESPONSE_HEADERS) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) reply.header(header, value);
|
||||
}
|
||||
|
||||
if (binary) {
|
||||
return reply.send(Buffer.from(await upstream.arrayBuffer()));
|
||||
}
|
||||
return reply.send(await upstream.text());
|
||||
}
|
||||
|
||||
function readHeader(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] || "" : value || "";
|
||||
}
|
||||
|
|
@ -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()
|
||||
`;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue