feat: add Ops Codex npm setup flow
This commit is contained in:
parent
3acf758743
commit
5e79a53e13
|
|
@ -10,6 +10,9 @@ LOG_LEVEL=info
|
||||||
# explicit localhost/LAN/Tailscale URL only for a named local/tunnel profile.
|
# 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_PUBLIC_URL=https://ops-agents.nodedc.ru
|
||||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-gateway-internal-token
|
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_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200
|
||||||
NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc
|
NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc
|
||||||
NODEDC_TASKER_INTERNAL_URL=http://task.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_PUBLIC_URL=https://ops-agents.nodedc.ru
|
||||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token
|
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_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200
|
||||||
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
|
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
|
||||||
NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
|
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}
|
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_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_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_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_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://launcher.local.nodedc}
|
||||||
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://task.local.nodedc}
|
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://task.local.nodedc}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
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_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru}
|
||||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}
|
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_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_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_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -7,6 +7,7 @@ import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
|
||||||
import { AgentsRepository } from "./repositories/agents.js";
|
import { AgentsRepository } from "./repositories/agents.js";
|
||||||
import { registerAgentRoutes } from "./routes/agents.js";
|
import { registerAgentRoutes } from "./routes/agents.js";
|
||||||
import { registerHealthRoutes } from "./routes/health.js";
|
import { registerHealthRoutes } from "./routes/health.js";
|
||||||
|
import { registerInstallerRoutes } from "./routes/install.js";
|
||||||
import { registerMcpRoutes } from "./routes/mcp.js";
|
import { registerMcpRoutes } from "./routes/mcp.js";
|
||||||
import { registerPublicRoutes } from "./routes/public.js";
|
import { registerPublicRoutes } from "./routes/public.js";
|
||||||
import { registerToolRoutes } from "./routes/tools.js";
|
import { registerToolRoutes } from "./routes/tools.js";
|
||||||
|
|
@ -127,9 +128,12 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerPublicRoutes(app);
|
await registerPublicRoutes(app);
|
||||||
|
await registerInstallerRoutes(app, { publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL });
|
||||||
await registerHealthRoutes(app, config, pool);
|
await registerHealthRoutes(app, config, pool);
|
||||||
await registerAgentRoutes(app, {
|
await registerAgentRoutes(app, {
|
||||||
agentsRepository,
|
agentsRepository,
|
||||||
|
codexInstallChannel: config.NODEDC_OPS_CODEX_INSTALL_CHANNEL,
|
||||||
|
codexNpmSpec: config.NODEDC_OPS_CODEX_NPM_SPEC,
|
||||||
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
|
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
|
||||||
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
|
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
|
||||||
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
|
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { z } from "zod";
|
||||||
|
|
||||||
const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional());
|
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({
|
const configSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
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"),
|
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_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"),
|
||||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(),
|
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_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_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
|
||||||
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
|
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
|
||||||
|
|
|
||||||
|
|
@ -118,32 +118,36 @@ const structuredBlocksJsonSchema = {
|
||||||
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
{
|
{
|
||||||
name: "tasker_get_agent_instructions",
|
name: "tasker_get_agent_instructions",
|
||||||
title: "Get Agent Instructions",
|
title: "NODE.DC Ops: Get Agent Instructions",
|
||||||
description: "Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.",
|
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"],
|
requiredScopes: ["workspace:read"],
|
||||||
inputSchema: emptyInputSchema,
|
inputSchema: emptyInputSchema,
|
||||||
annotations: { readOnlyHint: true },
|
annotations: { readOnlyHint: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_list_projects",
|
name: "tasker_list_projects",
|
||||||
title: "List Granted Projects",
|
title: "NODE.DC Ops: List Granted Projects",
|
||||||
description: "List Tasker projects granted to the current agent.",
|
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"],
|
requiredScopes: ["project:read"],
|
||||||
inputSchema: emptyInputSchema,
|
inputSchema: emptyInputSchema,
|
||||||
annotations: { readOnlyHint: true },
|
annotations: { readOnlyHint: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_get_project_context",
|
name: "tasker_get_project_context",
|
||||||
title: "Get Project Context",
|
title: "NODE.DC Ops: Get Project Context",
|
||||||
description: "Return states, labels, members, and card-writing context for one granted project.",
|
description:
|
||||||
|
"Direct NODE.DC Ops MCP tool. Return states, labels, members, and card-writing context for one granted Tasker/Ops project.",
|
||||||
requiredScopes: ["project:read"],
|
requiredScopes: ["project:read"],
|
||||||
inputSchema: projectInputSchema,
|
inputSchema: projectInputSchema,
|
||||||
annotations: { readOnlyHint: true },
|
annotations: { readOnlyHint: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_search_issues",
|
name: "tasker_search_issues",
|
||||||
title: "Search Issues",
|
title: "NODE.DC Ops: Search Cards",
|
||||||
description: "Search work items inside one granted Tasker project.",
|
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"],
|
requiredScopes: ["issue:read"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectInputSchema,
|
...projectInputSchema,
|
||||||
|
|
@ -156,8 +160,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_create_issue",
|
name: "tasker_create_issue",
|
||||||
title: "Create Issue",
|
title: "NODE.DC Ops: Create Card",
|
||||||
description: "Create a Tasker card with optional NODE.DC structured text/checker blocks.",
|
description:
|
||||||
|
"Direct NODE.DC Ops MCP write tool. Create a Tasker/Ops card with optional NODE.DC structured text/checker blocks.",
|
||||||
requiredScopes: ["issue:create"],
|
requiredScopes: ["issue:create"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
|
|
@ -177,8 +182,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_update_issue",
|
name: "tasker_update_issue",
|
||||||
title: "Update Issue",
|
title: "NODE.DC Ops: Update Card",
|
||||||
description: "Patch allowed issue fields without delete, archive, or project transfer.",
|
description:
|
||||||
|
"Direct NODE.DC Ops MCP write tool. Patch allowed Tasker/Ops card fields without delete, archive, or project transfer.",
|
||||||
requiredScopes: ["issue:update"],
|
requiredScopes: ["issue:update"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
|
|
@ -199,8 +205,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_update_structured_blocks",
|
name: "tasker_update_structured_blocks",
|
||||||
title: "Update Structured Blocks",
|
title: "NODE.DC Ops: Update Structured Blocks",
|
||||||
description: "Replace NODE.DC structured text/checker blocks in an issue detail layout.",
|
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"],
|
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectAndIssueInputSchema,
|
...projectAndIssueInputSchema,
|
||||||
|
|
@ -215,8 +222,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_move_issue",
|
name: "tasker_move_issue",
|
||||||
title: "Move Issue",
|
title: "NODE.DC Ops: Move Card",
|
||||||
description: "Move an issue to an existing state in the same granted project.",
|
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"],
|
requiredScopes: ["issue:move"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectAndIssueInputSchema,
|
...projectAndIssueInputSchema,
|
||||||
|
|
@ -231,8 +238,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_append_comment",
|
name: "tasker_append_comment",
|
||||||
title: "Append Comment",
|
title: "NODE.DC Ops: Append Comment",
|
||||||
description: "Append a comment to a granted issue.",
|
description: "Direct NODE.DC Ops MCP write tool. Append a comment to a granted Tasker/Ops card.",
|
||||||
requiredScopes: ["issue:comment"],
|
requiredScopes: ["issue:comment"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectAndIssueInputSchema,
|
...projectAndIssueInputSchema,
|
||||||
|
|
@ -247,8 +254,9 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_ensure_labels",
|
name: "tasker_ensure_labels",
|
||||||
title: "Ensure Project Labels",
|
title: "NODE.DC Ops: Ensure Project Labels",
|
||||||
description: "Create missing labels in a granted project and return label ids for subsequent issue labeling.",
|
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"],
|
requiredScopes: ["issue:label"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectInputSchema,
|
...projectInputSchema,
|
||||||
|
|
@ -279,8 +287,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_set_issue_labels",
|
name: "tasker_set_issue_labels",
|
||||||
title: "Set Issue Labels",
|
title: "NODE.DC Ops: Set Card Labels",
|
||||||
description: "Replace issue labels with existing or ensured labels from the granted project.",
|
description: "Direct NODE.DC Ops MCP write tool. Replace card labels with existing or ensured labels from the granted project.",
|
||||||
requiredScopes: ["issue:label"],
|
requiredScopes: ["issue:label"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectAndIssueInputSchema,
|
...projectAndIssueInputSchema,
|
||||||
|
|
@ -295,8 +303,8 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasker_assign_issue",
|
name: "tasker_assign_issue",
|
||||||
title: "Assign Issue",
|
title: "NODE.DC Ops: Assign Card",
|
||||||
description: "Replace issue assignees with existing members of the granted project.",
|
description: "Direct NODE.DC Ops MCP write tool. Replace card assignees with existing members of the granted project.",
|
||||||
requiredScopes: ["issue:assign"],
|
requiredScopes: ["issue:assign"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectAndIssueInputSchema,
|
...projectAndIssueInputSchema,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
import { allowedAgentScopes, type AgentScope } from "../domain/scopes.js";
|
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);
|
const allowedScopeSet = new Set<string>(allowedAgentScopes);
|
||||||
|
|
||||||
export type AgentStatus = "active" | "disabled" | "revoked";
|
export type AgentStatus = "active" | "disabled" | "revoked";
|
||||||
export type AgentGrantMode = "voluntary" | "reporting";
|
export type AgentGrantMode = "voluntary" | "reporting";
|
||||||
|
export type AgentSetupCodeStatus = "active" | "used" | "expired" | "revoked";
|
||||||
export type AgentTokenStatus = "active" | "revoked" | "expired";
|
export type AgentTokenStatus = "active" | "revoked" | "expired";
|
||||||
export type AgentTokenGrantScope = "agent" | "token";
|
export type AgentTokenGrantScope = "agent" | "token";
|
||||||
|
|
||||||
|
|
@ -54,6 +55,19 @@ export type AgentSessionRecord = {
|
||||||
grantSource: AgentTokenGrantScope;
|
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 = {
|
type AgentRow = {
|
||||||
id: string;
|
id: string;
|
||||||
owner_user_id: string;
|
owner_user_id: string;
|
||||||
|
|
@ -89,6 +103,20 @@ type TokenRow = {
|
||||||
created_at: Date;
|
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 & {
|
type SessionRow = AgentRow & {
|
||||||
token_id: string;
|
token_id: string;
|
||||||
token_name: string;
|
token_name: string;
|
||||||
|
|
@ -173,6 +201,13 @@ export type CreateTokenInput = {
|
||||||
tokenGrants?: CreateTokenGrantInput[];
|
tokenGrants?: CreateTokenGrantInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateSetupCodeInput = {
|
||||||
|
code: string;
|
||||||
|
createdByUserId?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
tokenName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateTokenGrantInput = {
|
export type CreateTokenGrantInput = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
@ -198,6 +233,18 @@ export type IdempotencyClaim =
|
||||||
status: "conflict";
|
status: "conflict";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RedeemSetupCodeResult =
|
||||||
|
| {
|
||||||
|
status: "redeemed";
|
||||||
|
agent: AgentRecord;
|
||||||
|
token: AgentTokenRecord;
|
||||||
|
grants: AgentGrantRecord[];
|
||||||
|
setupCode: AgentSetupCodeRecord;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "invalid" | "expired" | "used" | "revoked";
|
||||||
|
};
|
||||||
|
|
||||||
export class AgentsRepository {
|
export class AgentsRepository {
|
||||||
constructor(private readonly pool: Pool) {}
|
constructor(private readonly pool: Pool) {}
|
||||||
|
|
||||||
|
|
@ -544,6 +591,168 @@ export class AgentsRepository {
|
||||||
return tokenRecord;
|
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[]> {
|
async listTokens(agentId: string): Promise<AgentTokenRecord[]> {
|
||||||
const result = await this.pool.query<TokenRow>(
|
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 {
|
function mapTokenGrant(row: TokenGrantRow): AgentGrantRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { mcpToolDefinitions } from "../mcp/tools.js";
|
||||||
import type {
|
import type {
|
||||||
AgentGrantRecord,
|
AgentGrantRecord,
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
|
AgentSetupCodeRecord,
|
||||||
AgentSessionRecord,
|
AgentSessionRecord,
|
||||||
AgentTokenRecord,
|
AgentTokenRecord,
|
||||||
AgentsRepository,
|
AgentsRepository,
|
||||||
|
|
@ -15,10 +16,12 @@ import type {
|
||||||
} from "../repositories/agents.js";
|
} from "../repositories/agents.js";
|
||||||
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
|
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
|
||||||
import { requireInternalAccess } from "../security/internal.js";
|
import { requireInternalAccess } from "../security/internal.js";
|
||||||
import { generateAgentToken } from "../security/tokens.js";
|
import { generateAgentSetupCode, generateAgentToken } from "../security/agent-access.js";
|
||||||
|
|
||||||
type AgentRouteDeps = {
|
type AgentRouteDeps = {
|
||||||
agentsRepository: AgentsRepository | null;
|
agentsRepository: AgentsRepository | null;
|
||||||
|
codexInstallChannel: "registry" | "tarball";
|
||||||
|
codexNpmSpec: string;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
internalAccessToken?: string;
|
internalAccessToken?: string;
|
||||||
aiWorkspaceRunTokenTtlSeconds: number;
|
aiWorkspaceRunTokenTtlSeconds: number;
|
||||||
|
|
@ -28,6 +31,11 @@ const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
|
||||||
const OPS_APP_ID = "ops";
|
const OPS_APP_ID = "ops";
|
||||||
const OPS_APP_TITLE = "NODE.DC Ops";
|
const OPS_APP_TITLE = "NODE.DC Ops";
|
||||||
const OPS_MCP_SERVER_NAME = "nodedc_ops_agent";
|
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 {
|
function isAllowedAvatarUrl(value: string): boolean {
|
||||||
if (value.startsWith("data:image/")) {
|
if (value.startsWith("data:image/")) {
|
||||||
|
|
@ -125,6 +133,15 @@ const createTokenBodySchema = z.object({
|
||||||
expires_at: z.string().datetime().nullish(),
|
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({
|
const actorBodySchema = z.object({
|
||||||
actor_user_id: z.string().min(1).optional(),
|
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) => {
|
app.post("/api/internal/v1/ai-workspace/entitlements", async (request, reply) => {
|
||||||
requireLifecycleAccess(request, deps);
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(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) => {
|
app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => {
|
||||||
requireLifecycleAccess(request, deps);
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(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> {
|
function serializeAgentSession(session: AgentSessionRecord): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
agent: serializeAgent(session.agent),
|
agent: serializeAgent(session.agent),
|
||||||
|
|
@ -1093,7 +1234,7 @@ function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Recor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mcp_server: {
|
mcp_server: {
|
||||||
name: "nodedc_tasker",
|
name: CODEX_MCP_SERVER_NAME,
|
||||||
transport: "streamable_http_json_rpc",
|
transport: "streamable_http_json_rpc",
|
||||||
url: endpoint,
|
url: endpoint,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -1104,7 +1245,7 @@ function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Recor
|
||||||
},
|
},
|
||||||
codex_config_template: {
|
codex_config_template: {
|
||||||
mcp_servers: {
|
mcp_servers: {
|
||||||
nodedc_tasker: {
|
[CODEX_MCP_SERVER_NAME]: {
|
||||||
url: endpoint,
|
url: endpoint,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
required: false,
|
required: false,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
const TOKEN_PREFIX = "ndcag";
|
const TOKEN_PREFIX = "ndcag";
|
||||||
|
const SETUP_CODE_PREFIX = "ndcsetup";
|
||||||
|
|
||||||
export function generateAgentToken(): string {
|
export function generateAgentToken(): string {
|
||||||
return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
|
return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
|
||||||
|
|
@ -9,3 +10,11 @@ export function generateAgentToken(): string {
|
||||||
export function hashAgentToken(token: string): string {
|
export function hashAgentToken(token: string): string {
|
||||||
return createHash("sha256").update(token).digest("hex");
|
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