feat(ops): add workspace MCP deployment overlays
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import type { AppConfig } from "./config.js";
|
||||
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 { registerOntologyGatewayRoutes } from "./routes/ontology.js";
|
||||
import { registerPublicRoutes } from "./routes/public.js";
|
||||
import { registerToolRoutes } from "./routes/tools.js";
|
||||
import { ForbiddenError } from "./security/authorization.js";
|
||||
import { UnauthorizedError } from "./security/bearer.js";
|
||||
import { InternalAuthNotConfiguredError } from "./security/internal.js";
|
||||
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
|
||||
|
||||
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
const pool = createPool(config);
|
||||
const agentsRepository = pool ? new AgentsRepository(pool) : null;
|
||||
const taskerClient = new TaskerClient({
|
||||
baseUrl: config.NODEDC_TASKER_INTERNAL_URL,
|
||||
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
});
|
||||
const app = Fastify({
|
||||
bodyLimit: 10 * 1024 * 1024,
|
||||
logger: {
|
||||
level: config.LOG_LEVEL,
|
||||
},
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await pool?.end();
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
if (error instanceof ZodError) {
|
||||
void reply.status(400).send({
|
||||
ok: false,
|
||||
error: "validation_error",
|
||||
details: error.issues,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "database_not_configured",
|
||||
message: "DATABASE_URL is required for Agent Gateway persistence endpoints.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof UnauthorizedError) {
|
||||
void reply.status(401).send({
|
||||
ok: false,
|
||||
error: "unauthorized",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof InternalAuthNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "internal_auth_not_configured",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ForbiddenError) {
|
||||
void reply.status(403).send({
|
||||
ok: false,
|
||||
error: "forbidden",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ToolExecutionInputError) {
|
||||
void reply.status(error.httpStatus).send({
|
||||
ok: false,
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_not_configured",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterError) {
|
||||
void reply.status(error.statusCode).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_error",
|
||||
message: error.message,
|
||||
tasker_status: error.statusCode,
|
||||
tasker_payload: error.payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterUnavailableError) {
|
||||
void reply.status(502).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_unavailable",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
void reply.status(500).send({
|
||||
ok: false,
|
||||
error: "internal_server_error",
|
||||
message: "Agent Gateway request failed.",
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
await registerOntologyGatewayRoutes(app, {
|
||||
agentsRepository,
|
||||
ontologyCoreUrl: config.NODEDC_ONTOLOGY_CORE_URL,
|
||||
ontologyCoreAccessToken: config.NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN ?? config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
+36
@@ -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 independent `nodedc-ops-agent` and read-only `nodedc_ontology` MCP server blocks into `~/.codex/config.toml`, installs the `ops-context` skill, and checks both MCP tool lists.
|
||||
|
||||
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.
|
||||
+792
@@ -0,0 +1,792 @@
|
||||
#!/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 ONTOLOGY_SERVER_NAME = "nodedc_ontology";
|
||||
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 setup = redeemPayload.setup || {};
|
||||
const mcpServer = setup.mcp_server || {};
|
||||
const ops = redeemPayload.ops || {
|
||||
serverName: SERVER_NAME,
|
||||
mcpUrl: String(mcpServer.url || `${gateway}/mcp`),
|
||||
token: redeemPayload.token,
|
||||
};
|
||||
const ontology = redeemPayload.ontology;
|
||||
if (!ops.token || !ontology?.token) {
|
||||
throw new Error("Setup code redeem did not return independent Ops and Ontology credentials.");
|
||||
}
|
||||
const opsEndpoint = String(ops.mcpUrl || `${gateway}/mcp`);
|
||||
const ontologyEndpoint = String(ontology.mcpUrl || `${gateway}/ontology-mcp`);
|
||||
const agentsMd = setup.agents_md || defaultSkillBody(opsEndpoint);
|
||||
|
||||
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, [
|
||||
{ serverName: String(ops.serverName || SERVER_NAME), endpoint: opsEndpoint, token: ops.token },
|
||||
{
|
||||
serverName: String(ontology.serverName || ONTOLOGY_SERVER_NAME),
|
||||
endpoint: ontologyEndpoint,
|
||||
token: ontology.token,
|
||||
},
|
||||
]);
|
||||
await writeSkill(skillPath, agentsMd, opsEndpoint, ontologyEndpoint);
|
||||
}
|
||||
const opsToolCount = await smokeToolsList(opsEndpoint, ops.token);
|
||||
const ontologyToolCount = await smokeToolsList(ontologyEndpoint, ontology.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("Ops MCP smoke tools:", opsToolCount);
|
||||
console.log("Ontology MCP smoke tools:", ontologyToolCount);
|
||||
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 || !data.ontology?.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, servers) {
|
||||
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 || "",
|
||||
servers.map((server) => server.serverName)
|
||||
).trimEnd();
|
||||
if (nextText) {
|
||||
nextText += "\n\n";
|
||||
}
|
||||
nextText += servers
|
||||
.map((server) => buildMcpConfigBlock(server.serverName, server.endpoint, server.token))
|
||||
.join("\n\n");
|
||||
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, serverNames) {
|
||||
const targets = new Set(serverNames.map((serverName) => `mcp_servers.${serverName}`));
|
||||
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 = [...targets].some((target) => section === target || section.startsWith(`${target}.`));
|
||||
}
|
||||
if (!skip) {
|
||||
kept.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function buildMcpConfigBlock(serverName, endpoint, token) {
|
||||
return [
|
||||
`[mcp_servers.${serverName}]`,
|
||||
`url = ${tomlString(endpoint)}`,
|
||||
"enabled = true",
|
||||
"required = false",
|
||||
"startup_timeout_sec = 20",
|
||||
"tool_timeout_sec = 60",
|
||||
"",
|
||||
`[mcp_servers.${serverName}.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, ontologyEndpoint) {
|
||||
await mkdir(path.dirname(skillPath), { recursive: true });
|
||||
await writeFile(skillPath, buildSkillBody(agentsMd, endpoint, ontologyEndpoint), "utf8");
|
||||
}
|
||||
|
||||
function buildSkillBody(agentsMd, endpoint, ontologyEndpoint) {
|
||||
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.",
|
||||
"- Treat `nodedc_ontology` as a separate read-only semantic authority. Use it before inventing entity ids, semantic types, aliases, relations, or platform routing assumptions.",
|
||||
"- Ontology tools and credentials are never embedded or multiplexed into Ops MCP.",
|
||||
"- 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.",
|
||||
"- Read a card with `tasker_get_issue` before claiming its comments or attachments are unavailable.",
|
||||
"- Create projects only through `tasker_create_project` when the effective workspace grant includes `project:create`.",
|
||||
"- Attach local files only through `tasker_attach_file`; never pass arbitrary server paths or remote URLs.",
|
||||
"- Do not delete or archive cards, comments, labels, projects, states, members, or workspaces.",
|
||||
"",
|
||||
"## Installed Endpoint",
|
||||
"",
|
||||
endpoint,
|
||||
"",
|
||||
"## Separate read-only Ontology endpoint",
|
||||
"",
|
||||
ontologyEndpoint,
|
||||
"",
|
||||
"## 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 opsConfig = configText ? parseCodexMcpConfig(configText, SERVER_NAME) : null;
|
||||
const ontologyConfig = configText ? parseCodexMcpConfig(configText, ONTOLOGY_SERVER_NAME) : null;
|
||||
const endpoint = opsConfig?.server.url || "";
|
||||
const authorization = opsConfig?.headers.Authorization || "";
|
||||
const ontologyEndpoint = ontologyConfig?.server.url || "";
|
||||
const ontologyAuthorization = ontologyConfig?.headers.Authorization || "";
|
||||
|
||||
checks.push({ ok: Boolean(configText), name: "config", detail: configPath });
|
||||
checks.push({
|
||||
ok: Boolean(opsConfig?.found),
|
||||
name: "Ops MCP server",
|
||||
detail: opsConfig?.found ? 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(ontologyConfig?.found),
|
||||
name: "Ontology MCP server",
|
||||
detail: ontologyConfig?.found ? ONTOLOGY_SERVER_NAME : "missing [mcp_servers.nodedc_ontology]",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(ontologyEndpoint),
|
||||
name: "Ontology endpoint",
|
||||
detail: ontologyEndpoint || "missing url",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(ontologyAuthorization),
|
||||
name: "Ontology authorization",
|
||||
detail: ontologyAuthorization ? "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 && ontologyEndpoint && ontologyAuthorization) {
|
||||
try {
|
||||
const opsToolCount = await smokeToolsListWithAuthorization(endpoint, authorization);
|
||||
const ontologyToolCount = await smokeToolsListWithAuthorization(ontologyEndpoint, ontologyAuthorization);
|
||||
smoke = { ok: true, detail: `${opsToolCount} Ops tools; ${ontologyToolCount} Ontology tools` };
|
||||
} catch (error) {
|
||||
smoke = { ok: false, detail: error.message || String(error) };
|
||||
}
|
||||
} else {
|
||||
smoke = { ok: false, detail: "Ops/Ontology 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, serverName) {
|
||||
const target = `mcp_servers.${serverName}`;
|
||||
const headers = `${target}.http_headers`;
|
||||
let current = "";
|
||||
const result = { found: false, 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();
|
||||
if (current === target || current === headers) {
|
||||
result.found = true;
|
||||
}
|
||||
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);
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@nodedc/ops-codex",
|
||||
"version": "0.1.3",
|
||||
"description": "Install scoped NODE.DC Ops MCP and separate read-only Ontology 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional());
|
||||
const optionalSecret = z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).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"),
|
||||
HOST: z.string().min(1).default("0.0.0.0"),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4100),
|
||||
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_ONTOLOGY_CORE_URL: z.string().url().default("http://172.22.0.222:18104"),
|
||||
NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN: optionalSecret,
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
|
||||
DATABASE_URL: optionalUrl,
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const parsed = configSchema.safeParse(env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
||||
throw new Error(`Invalid Agent Gateway configuration: ${details}`);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export const allowedAgentScopes = [
|
||||
"workspace:read",
|
||||
"project:read",
|
||||
"project:create",
|
||||
"project:member:add_existing",
|
||||
"issue:read",
|
||||
"issue:create",
|
||||
"issue:update",
|
||||
"issue:move",
|
||||
"issue:comment",
|
||||
"issue:label",
|
||||
"issue:assign",
|
||||
"issue:attachment:write",
|
||||
"issue:structured_blocks:write",
|
||||
] as const;
|
||||
|
||||
export type AgentScope = (typeof allowedAgentScopes)[number];
|
||||
|
||||
export const deniedMvpCapabilities = [
|
||||
"issue:delete",
|
||||
"issue:archive",
|
||||
"comment:delete",
|
||||
"label:delete",
|
||||
"state:create",
|
||||
"state:delete",
|
||||
"project:delete",
|
||||
"workspace:settings",
|
||||
"workspace:member:invite",
|
||||
"workspace:member:remove",
|
||||
"raw_tasker_api",
|
||||
] as const;
|
||||
|
||||
export const taskAuthorPresetScopes: AgentScope[] = [
|
||||
"workspace:read",
|
||||
"project:read",
|
||||
"project:create",
|
||||
"project:member:add_existing",
|
||||
"issue:read",
|
||||
"issue:create",
|
||||
"issue:update",
|
||||
"issue:move",
|
||||
"issue:comment",
|
||||
"issue:label",
|
||||
"issue:assign",
|
||||
"issue:attachment:write",
|
||||
"issue:structured_blocks:write",
|
||||
];
|
||||
|
||||
export const reporterPresetScopes: AgentScope[] = [
|
||||
"workspace:read",
|
||||
"project:read",
|
||||
"issue:read",
|
||||
"issue:update",
|
||||
"issue:comment",
|
||||
"issue:structured_blocks:write",
|
||||
];
|
||||
+849
@@ -0,0 +1,849 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentScope } from "../domain/scopes.js";
|
||||
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
|
||||
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
|
||||
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
|
||||
import type { TaskerClient } from "../tasker/client.js";
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
export type McpToolRuntimeDefinition = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requiredScopes: AgentScope[];
|
||||
inputSchema: JsonSchema;
|
||||
annotations?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type McpToolResult = {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
structuredContent?: unknown;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type ExecuteToolDeps = {
|
||||
agentsRepository?: AgentsRepository | null;
|
||||
taskerClient: TaskerClient;
|
||||
};
|
||||
|
||||
type ExecuteToolOptions = {
|
||||
source?: "mcp" | "rest";
|
||||
idempotencyKey?: string | null;
|
||||
};
|
||||
|
||||
const emptyInputSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const projectInputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
},
|
||||
required: ["project_id"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const projectAndIssueInputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string" },
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const structuredBlocksJsonSchema = {
|
||||
type: "array",
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { const: "text" },
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Visible block title. Put headings here, not inside body markdown.",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "Plain block body without a leading markdown heading.",
|
||||
},
|
||||
},
|
||||
required: ["id", "type", "title", "body"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { const: "checker" },
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Visible checklist title. Put headings here, not inside item text.",
|
||||
},
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
checked: { type: "boolean" },
|
||||
},
|
||||
required: ["id", "text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["id", "type", "title", "items"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||
{
|
||||
name: "tasker_get_agent_instructions",
|
||||
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: "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: "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_create_project",
|
||||
title: "NODE.DC Ops: Create Project",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create a new Tasker project inside an explicitly granted workspace and extend the current agent grant to the created project.",
|
||||
requiredScopes: ["project:create"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspace_slug: { type: "string" },
|
||||
name: { type: "string" },
|
||||
identifier: {
|
||||
type: "string",
|
||||
description: "Optional 1-12 character Tasker project identifier. A unique identifier is generated when omitted.",
|
||||
},
|
||||
description: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["workspace_slug", "name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_search_issues",
|
||||
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,
|
||||
properties: {
|
||||
...projectInputSchema.properties,
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_get_issue",
|
||||
title: "NODE.DC Ops: Get Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return one granted Tasker/Ops card with structured blocks, labels, assignees, full comment bodies, and attachment metadata.",
|
||||
requiredScopes: ["issue:read"],
|
||||
inputSchema: projectAndIssueInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_create_issue",
|
||||
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",
|
||||
properties: {
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
||||
structured_blocks: structuredBlocksJsonSchema,
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["project_id", "title"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_update_issue",
|
||||
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",
|
||||
properties: {
|
||||
issue_id: { type: "string" },
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
||||
structured_blocks: structuredBlocksJsonSchema,
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_update_structured_blocks",
|
||||
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,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
structured_blocks: structuredBlocksJsonSchema,
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "structured_blocks"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_move_issue",
|
||||
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,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
state_id: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "state_id"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_append_comment",
|
||||
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,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
body: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "body"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_attach_file",
|
||||
title: "NODE.DC Ops: Attach File",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Attach a base64-encoded local file to a granted card through Tasker's existing storage, quota, and deduplication path.",
|
||||
requiredScopes: ["issue:attachment:write"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
file_name: { type: "string", description: "File name only; paths are rejected." },
|
||||
mime_type: { type: "string" },
|
||||
content_base64: {
|
||||
type: "string",
|
||||
maxLength: 7000000,
|
||||
contentEncoding: "base64",
|
||||
description: "Base64 file content. Raw decoded size must not exceed 5 MiB.",
|
||||
},
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "file_name", "mime_type", "content_base64"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_ensure_labels",
|
||||
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,
|
||||
properties: {
|
||||
...projectInputSchema.properties,
|
||||
labels: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 50,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
color: {
|
||||
type: "string",
|
||||
description: "Optional hex color in #RRGGBB format.",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["project_id", "labels"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_set_issue_labels",
|
||||
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,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
label_ids: { type: "array", items: { type: "string" } },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "label_ids"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_assign_issue",
|
||||
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,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
member_ids: { type: "array", items: { type: "string" } },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "member_ids"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
];
|
||||
|
||||
const emptyArgsSchema = z.object({}).default({});
|
||||
const projectArgsSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
});
|
||||
const createProjectArgsSchema = z.object({
|
||||
workspace_slug: z.string().min(1).max(255),
|
||||
name: z.string().min(1).max(255),
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9_]+$/)
|
||||
.optional(),
|
||||
description: z.string().max(20_000).optional(),
|
||||
});
|
||||
const searchIssuesArgsSchema = projectArgsSchema.extend({
|
||||
query: z.string().min(1).optional(),
|
||||
});
|
||||
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
|
||||
const createIssueArgsSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
title: z.string().min(1).max(500),
|
||||
description: z.string().max(20000).optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
structured_blocks: structuredBlocksSchema.optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
});
|
||||
const issueArgsSchema = z.object({
|
||||
issue_id: z.string().min(1),
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
});
|
||||
const updateIssueArgsSchema = issueArgsSchema.extend({
|
||||
title: z.string().min(1).max(500).optional(),
|
||||
description: z.string().max(20000).optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
structured_blocks: structuredBlocksSchema.optional(),
|
||||
});
|
||||
const structuredBlocksArgsSchema = issueArgsSchema.extend({
|
||||
structured_blocks: structuredBlocksSchema,
|
||||
});
|
||||
const moveIssueArgsSchema = issueArgsSchema.extend({
|
||||
state_id: z.string().min(1),
|
||||
});
|
||||
const commentArgsSchema = issueArgsSchema.extend({
|
||||
body: z.string().min(1).max(20000),
|
||||
});
|
||||
const attachFileArgsSchema = issueArgsSchema
|
||||
.extend({
|
||||
file_name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine((value) => !/[\\/\0]/.test(value), "file_name must not contain a path"),
|
||||
mime_type: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/),
|
||||
content_base64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(7_000_000)
|
||||
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (Buffer.byteLength(input.content_base64, "base64") > 5 * 1024 * 1024) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["content_base64"],
|
||||
message: "Decoded attachment must not exceed 5 MiB.",
|
||||
});
|
||||
}
|
||||
});
|
||||
const ensureLabelsArgsSchema = projectArgsSchema.extend({
|
||||
labels: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
});
|
||||
const labelsArgsSchema = issueArgsSchema.extend({
|
||||
label_ids: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
const assigneesArgsSchema = issueArgsSchema.extend({
|
||||
member_ids: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
|
||||
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
|
||||
}
|
||||
|
||||
export async function executeMcpTool(
|
||||
session: AgentSessionRecord,
|
||||
name: string,
|
||||
rawArguments: unknown,
|
||||
deps: ExecuteToolDeps,
|
||||
options: ExecuteToolOptions = {}
|
||||
): Promise<McpToolResult> {
|
||||
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown MCP tool: ${name}`);
|
||||
}
|
||||
|
||||
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
|
||||
const isWriteTool = tool.annotations?.readOnlyHint !== true;
|
||||
|
||||
if (!isWriteTool) {
|
||||
return executeMcpToolOnce(session, name, args, deps);
|
||||
}
|
||||
|
||||
if (!deps.agentsRepository) {
|
||||
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
|
||||
}
|
||||
|
||||
if (!idempotencyKey) {
|
||||
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
|
||||
}
|
||||
|
||||
const requestHash = hashToolRequest(name, args);
|
||||
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
|
||||
|
||||
if (claim.status === "replay") {
|
||||
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
|
||||
source: options.source ?? "mcp",
|
||||
toolName: name,
|
||||
idempotencyKey,
|
||||
});
|
||||
return claim.responseBody as McpToolResult;
|
||||
}
|
||||
|
||||
if (claim.status === "conflict") {
|
||||
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
|
||||
}
|
||||
|
||||
if (claim.status === "in_progress") {
|
||||
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
|
||||
lockedUntil: claim.lockedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeMcpToolOnce(session, name, args, deps);
|
||||
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
|
||||
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
|
||||
source: options.source ?? "mcp",
|
||||
toolName: name,
|
||||
idempotencyKey,
|
||||
arguments: summarizeToolArguments(args),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
|
||||
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
|
||||
source: options.source ?? "mcp",
|
||||
toolName: name,
|
||||
idempotencyKey,
|
||||
error: error instanceof Error ? error.name : "unknown_error",
|
||||
message: error instanceof Error ? error.message : "Unknown tool execution error.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeMcpToolOnce(
|
||||
session: AgentSessionRecord,
|
||||
name: string,
|
||||
args: unknown,
|
||||
deps: ExecuteToolDeps
|
||||
): Promise<McpToolResult> {
|
||||
|
||||
switch (name) {
|
||||
case "tasker_get_agent_instructions":
|
||||
emptyArgsSchema.parse(args);
|
||||
requireScope(session, "workspace:read");
|
||||
return asToolResult(buildAgentInstructions(session));
|
||||
case "tasker_list_projects":
|
||||
emptyArgsSchema.parse(args);
|
||||
requireScope(session, "project:read");
|
||||
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
|
||||
case "tasker_get_project_context": {
|
||||
const input = projectArgsSchema.parse(args);
|
||||
requireScope(session, "project:read");
|
||||
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
|
||||
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
|
||||
}
|
||||
case "tasker_create_project": {
|
||||
const input = createProjectArgsSchema.parse(args);
|
||||
if (session.grantSource !== "agent") {
|
||||
throw new ForbiddenError("Project creation requires an agent-scoped local token.");
|
||||
}
|
||||
const sourceGrant = requireWorkspaceScope(session, "project:create", input.workspace_slug);
|
||||
if (!deps.agentsRepository) {
|
||||
throw new ToolExecutionInputError("agent_repository_unavailable", "Agent Gateway persistence is required.", 503);
|
||||
}
|
||||
const payload = await deps.taskerClient.createProject(session, input);
|
||||
const createdProject = extractCreatedProject(payload);
|
||||
await deps.agentsRepository.upsertGrant(session.agent.id, {
|
||||
workspaceSlug: input.workspace_slug,
|
||||
projectId: createdProject.id,
|
||||
scopes: sourceGrant.scopes,
|
||||
mode: sourceGrant.mode,
|
||||
createdByUserId: session.agent.ownerUserId,
|
||||
});
|
||||
return asToolResult(payload);
|
||||
}
|
||||
case "tasker_search_issues": {
|
||||
const input = searchIssuesArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
|
||||
}
|
||||
case "tasker_get_issue": {
|
||||
const input = issueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.getIssue(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_create_issue": {
|
||||
const input = createIssueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.createIssue(session, input));
|
||||
}
|
||||
case "tasker_update_issue": {
|
||||
const input = updateIssueArgsSchema.parse(args);
|
||||
if (input.structured_blocks) {
|
||||
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
|
||||
} else {
|
||||
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
|
||||
}
|
||||
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_update_structured_blocks": {
|
||||
const input = structuredBlocksArgsSchema.parse(args);
|
||||
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
|
||||
return asToolResult(
|
||||
await deps.taskerClient.updateIssue(session, input.issue_id, {
|
||||
project_id: input.project_id,
|
||||
workspace_slug: input.workspace_slug,
|
||||
structured_blocks: input.structured_blocks,
|
||||
})
|
||||
);
|
||||
}
|
||||
case "tasker_move_issue": {
|
||||
const input = moveIssueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_append_comment": {
|
||||
const input = commentArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_attach_file": {
|
||||
const input = attachFileArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:attachment:write", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.attachFile(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_ensure_labels": {
|
||||
const input = ensureLabelsArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
|
||||
}
|
||||
case "tasker_set_issue_labels": {
|
||||
const input = labelsArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_assign_issue": {
|
||||
const input = assigneesArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown MCP tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
|
||||
return session.grants.some((grant) => grant.scopes.includes(scope));
|
||||
}
|
||||
|
||||
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
|
||||
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
|
||||
}
|
||||
|
||||
function requireWorkspaceScope(session: AgentSessionRecord, scope: AgentScope, workspaceSlug: string) {
|
||||
requireScope(session, scope);
|
||||
const grant = session.grants.find(
|
||||
(candidate) => candidate.workspaceSlug === workspaceSlug && candidate.scopes.includes(scope)
|
||||
);
|
||||
if (!grant) {
|
||||
throw new ForbiddenError(`Grant for workspace does not include required scope: ${scope}.`);
|
||||
}
|
||||
return grant;
|
||||
}
|
||||
|
||||
function requireProjectScopes(
|
||||
session: AgentSessionRecord,
|
||||
projectId: string,
|
||||
workspaceSlug: string | null | undefined,
|
||||
scopes: AgentScope[]
|
||||
): void {
|
||||
for (const scope of scopes) {
|
||||
requireScope(session, scope);
|
||||
}
|
||||
|
||||
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (!grant.scopes.includes(scope)) {
|
||||
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asToolResult(payload: unknown): McpToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
},
|
||||
],
|
||||
structuredContent: payload,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCreatedProject(payload: unknown): { id: string } {
|
||||
if (!isPlainRecord(payload) || !isPlainRecord(payload.project) || typeof payload.project.id !== "string") {
|
||||
throw new ToolExecutionInputError(
|
||||
"tasker_project_response_invalid",
|
||||
"Tasker did not return the created project id.",
|
||||
502
|
||||
);
|
||||
}
|
||||
return { id: payload.project.id };
|
||||
}
|
||||
|
||||
export class ToolExecutionInputError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly httpStatus: number,
|
||||
readonly details?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ToolExecutionInputError";
|
||||
}
|
||||
}
|
||||
|
||||
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
|
||||
if (!isPlainRecord(rawArguments)) {
|
||||
return {
|
||||
args: rawArguments ?? {},
|
||||
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
|
||||
};
|
||||
}
|
||||
|
||||
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
|
||||
|
||||
return {
|
||||
args,
|
||||
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIdempotencyKey(value?: string | null): string | null {
|
||||
const normalized = value?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.length > 200) {
|
||||
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hashToolRequest(name: string, args: unknown): string {
|
||||
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
}
|
||||
|
||||
if (isPlainRecord(value)) {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function summarizeToolArguments(args: unknown): Record<string, unknown> {
|
||||
if (!isPlainRecord(args)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
workspace_slug: args.workspace_slug,
|
||||
project_id: args.project_id,
|
||||
issue_id: args.issue_id,
|
||||
state_id: args.state_id,
|
||||
project_name: args.name,
|
||||
file_name: args.file_name,
|
||||
attachment_bytes:
|
||||
typeof args.content_base64 === "string" ? Buffer.byteLength(args.content_base64, "base64") : undefined,
|
||||
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
|
||||
has_structured_blocks: Array.isArray(args.structured_blocks),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
|
||||
return {
|
||||
agent: {
|
||||
id: session.agent.id,
|
||||
display_name: session.agent.displayName,
|
||||
owner_user_id: session.agent.ownerUserId,
|
||||
},
|
||||
grants: session.grants.map((grant) => ({
|
||||
workspace_slug: grant.workspaceSlug,
|
||||
project_id: grant.projectId,
|
||||
mode: grant.mode,
|
||||
scopes: grant.scopes,
|
||||
})),
|
||||
grant_source: session.grantSource,
|
||||
rules: {
|
||||
card_structure: [
|
||||
"Keep the main issue description concise and conceptual.",
|
||||
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
|
||||
"Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
|
||||
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
|
||||
"Use checker blocks with explicit titles for short verifiable phase items.",
|
||||
"After implementation, add a factual implementation block with files touched and validation performed.",
|
||||
],
|
||||
labels: [
|
||||
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
|
||||
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
|
||||
],
|
||||
hard_limits: [
|
||||
"Do not delete or archive issues.",
|
||||
"Create projects only through tasker_create_project when the effective workspace grant includes project:create.",
|
||||
"Attach files only through tasker_attach_file; never upload from arbitrary URLs or server paths.",
|
||||
"Do not create states, workspace invites, or workspace settings changes.",
|
||||
"Only create labels through tasker_ensure_labels inside granted projects.",
|
||||
"Only assign existing project members.",
|
||||
"Only use projects and workspaces present in effective grants.",
|
||||
],
|
||||
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
|
||||
},
|
||||
};
|
||||
}
|
||||
+1092
File diff suppressed because it is too large
Load Diff
+1389
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
import { DatabaseNotConfiguredError } from "../db/pool.js";
|
||||
import type { AgentsRepository } from "../repositories/agents.js";
|
||||
import { parseBearerToken } from "../security/bearer.js";
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
|
||||
type OntologyRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
ontologyCoreUrl: string;
|
||||
ontologyCoreAccessToken?: string;
|
||||
};
|
||||
|
||||
export async function registerOntologyGatewayRoutes(app: FastifyInstance, deps: OntologyRouteDeps): Promise<void> {
|
||||
app.post("/ontology-mcp", async (request, reply) => {
|
||||
if (!deps.agentsRepository) {
|
||||
throw new DatabaseNotConfiguredError();
|
||||
}
|
||||
|
||||
const token = parseBearerToken(request.headers.authorization);
|
||||
const session = await deps.agentsRepository.findActiveSessionByToken(token, "ontology");
|
||||
if (!session) {
|
||||
return reply.status(401).send({
|
||||
ok: false,
|
||||
error: "ontology_agent_unauthorized",
|
||||
message: "Ontology token is inactive, expired, revoked, or has the wrong purpose.",
|
||||
});
|
||||
}
|
||||
|
||||
const upstreamToken = deps.ontologyCoreAccessToken;
|
||||
if (!upstreamToken) {
|
||||
return reply.status(503).send({
|
||||
ok: false,
|
||||
error: "ontology_proxy_not_configured",
|
||||
message: "Ontology Core access token is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(new URL("/mcp", deps.ontologyCoreUrl), {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Accept: readHeader(request.headers.accept) ?? "application/json, text/event-stream",
|
||||
Authorization: `Bearer ${upstreamToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Protocol-Version": readHeader(request.headers["mcp-protocol-version"]) ?? MCP_PROTOCOL_VERSION,
|
||||
},
|
||||
body: JSON.stringify(request.body ?? {}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch {
|
||||
return reply.status(503).send({
|
||||
ok: false,
|
||||
error: "ontology_proxy_unavailable",
|
||||
message: "Ontology Core MCP is unavailable.",
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await upstream.text();
|
||||
reply.status(upstream.status);
|
||||
reply.header("Cache-Control", "no-store");
|
||||
reply.header("Content-Type", upstream.headers.get("content-type") ?? "application/json; charset=utf-8");
|
||||
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) {
|
||||
reply.header(header, value);
|
||||
}
|
||||
}
|
||||
return reply.send(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function readHeader(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
import { executeMcpTool } from "../mcp/tool-runtime.js";
|
||||
import type { AgentsRepository } from "../repositories/agents.js";
|
||||
import type { TaskerClient } from "../tasker/client.js";
|
||||
import { authenticateAgent } from "./session.js";
|
||||
|
||||
type ToolRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
taskerClient: TaskerClient;
|
||||
};
|
||||
|
||||
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
|
||||
app.post("/api/v1/tools/projects", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const result = await executeMcpTool(session, "tasker_create_project", request.body, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/projects", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { projectId: string };
|
||||
const query = request.query as { workspace_slug?: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_get_project_context",
|
||||
{
|
||||
project_id: params.projectId,
|
||||
workspace_slug: query.workspace_slug,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { projectId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_ensure_labels",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
project_id: params.projectId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
||||
const result = await executeMcpTool(session, "tasker_search_issues", query, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/issues/:issueId", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const query = request.query as { project_id?: string; workspace_slug?: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_get_issue",
|
||||
{ ...query, issue_id: params.issueId },
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_update_issue",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_move_issue",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_append_comment",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/attachments", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_attach_file",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_set_issue_labels",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_assign_issue",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
}
|
||||
|
||||
function toolOptions(request: { headers: Record<string, string | string[] | undefined> }): { source: "rest"; idempotencyKey: string | null } {
|
||||
return {
|
||||
source: "rest",
|
||||
idempotencyKey: readHeader(request.headers["idempotency-key"]),
|
||||
};
|
||||
}
|
||||
|
||||
function requestBodyRecord(body: unknown): Record<string, unknown> {
|
||||
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function readHeader(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
|
||||
return value ?? null;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const TOKEN_PREFIX = "ndcag";
|
||||
const ONTOLOGY_TOKEN_PREFIX = "ndcao";
|
||||
const SETUP_CODE_PREFIX = "ndcsetup";
|
||||
|
||||
export function generateAgentToken(): string {
|
||||
return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function generateOntologyAgentToken(): string {
|
||||
return `${ONTOLOGY_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");
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { AgentSessionRecord } from "../repositories/agents.js";
|
||||
|
||||
export class TaskerAdapterNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("NODEDC_INTERNAL_ACCESS_TOKEN is required for Tasker adapter calls.");
|
||||
this.name = "TaskerAdapterNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskerAdapterError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number,
|
||||
readonly payload: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TaskerAdapterError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskerAdapterUnavailableError extends Error {
|
||||
constructor(readonly causeError: unknown) {
|
||||
super("Tasker internal adapter is unavailable.");
|
||||
this.name = "TaskerAdapterUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskerClientConfig = {
|
||||
baseUrl: string;
|
||||
internalAccessToken?: string;
|
||||
};
|
||||
|
||||
export type TaskerAgentContext = {
|
||||
agentId: string;
|
||||
ownerUserId: string;
|
||||
tokenId: string;
|
||||
};
|
||||
|
||||
export type GrantedProjectInput = {
|
||||
workspace_slug: string;
|
||||
project_id: string | null;
|
||||
mode: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type ListGrantedProjectsInput = {
|
||||
grants: GrantedProjectInput[];
|
||||
};
|
||||
|
||||
export type CreateIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: "none" | "low" | "medium" | "high" | "urgent";
|
||||
structured_blocks?: unknown[];
|
||||
};
|
||||
|
||||
export type CreateProjectInput = {
|
||||
workspace_slug: string;
|
||||
name: string;
|
||||
identifier?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type GetIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: "none" | "low" | "medium" | "high" | "urgent";
|
||||
structured_blocks?: unknown[];
|
||||
};
|
||||
|
||||
export type MoveIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
state_id: string;
|
||||
};
|
||||
|
||||
export type CommentInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type AttachFileInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
content_base64: string;
|
||||
};
|
||||
|
||||
export type SetLabelsInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
label_ids: string[];
|
||||
};
|
||||
|
||||
export type EnsureLabelsInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
labels: Array<{
|
||||
name: string;
|
||||
color?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AssignIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
member_ids: string[];
|
||||
};
|
||||
|
||||
export class TaskerClient {
|
||||
constructor(private readonly config: TaskerClientConfig) {}
|
||||
|
||||
async listGrantedProjects(session: AgentSessionRecord): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/projects/resolve", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: {
|
||||
grants: session.grants.map((grant) => ({
|
||||
workspace_slug: grant.workspaceSlug,
|
||||
project_id: grant.projectId,
|
||||
mode: grant.mode,
|
||||
scopes: grant.scopes,
|
||||
})),
|
||||
} satisfies ListGrantedProjectsInput,
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectContext(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (workspaceSlug) {
|
||||
searchParams.set("workspace_slug", workspaceSlug);
|
||||
}
|
||||
|
||||
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(projectId)}/context?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async createProject(session: AgentSessionRecord, input: CreateProjectInput): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/projects", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async listIssues(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null, query?: string): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams({ project_id: projectId });
|
||||
|
||||
if (workspaceSlug) {
|
||||
searchParams.set("workspace_slug", workspaceSlug);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
searchParams.set("query", query);
|
||||
}
|
||||
|
||||
return this.request(`/api/internal/nodedc/agent/issues?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async getIssue(session: AgentSessionRecord, issueId: string, input: GetIssueInput): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams({ project_id: input.project_id });
|
||||
if (input.workspace_slug) {
|
||||
searchParams.set("workspace_slug", input.workspace_slug);
|
||||
}
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async createIssue(session: AgentSessionRecord, input: CreateIssueInput): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/issues", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, {
|
||||
method: "PATCH",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/move`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async appendComment(session: AgentSessionRecord, issueId: string, input: CommentInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async attachFile(session: AgentSessionRecord, issueId: string, input: AttachFileInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/attachments`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
private async request(
|
||||
path: string,
|
||||
input: {
|
||||
method: "GET" | "POST" | "PATCH" | "PUT";
|
||||
session: AgentSessionRecord;
|
||||
body?: unknown;
|
||||
}
|
||||
): Promise<unknown> {
|
||||
if (!this.config.internalAccessToken) {
|
||||
throw new TaskerAdapterNotConfiguredError();
|
||||
}
|
||||
|
||||
const response = await this.fetchTasker(path, input);
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TaskerAdapterError("Tasker internal adapter request failed.", response.status, payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async fetchTasker(
|
||||
path: string,
|
||||
input: {
|
||||
method: "GET" | "POST" | "PATCH" | "PUT";
|
||||
session: AgentSessionRecord;
|
||||
body?: unknown;
|
||||
}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const requestBody = attachAgentMetadata(input.session, input.body);
|
||||
return await fetch(new URL(path, this.config.baseUrl), {
|
||||
method: input.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.internalAccessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-NODEDC-Agent-Id": input.session.agent.id,
|
||||
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
|
||||
"X-NODEDC-Agent-Token-Id": input.session.token.id,
|
||||
},
|
||||
body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TaskerAdapterUnavailableError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
|
||||
if (body === undefined || !isPlainRecord(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
_agent: {
|
||||
display_name: session.agent.displayName,
|
||||
avatar_url: session.agent.avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user