diff --git a/README.md b/README.md index cad973e..79eadbe 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: | In the plugin | Outside (data / runtime) | |---------------|---------------------------| -| `netx__*` REST tools + settings + credentials | netx HTTP API (URL + token) | +| `netx__*` REST tools (Ops preset only) + settings + credentials | netx HTTP API (URL + token) | | Persona + UME / managed-NE skills | | | Agent preset auto-install to `~/.dsh/.agent-presets` | | diff --git a/cordis.patch.yml b/cordis.patch.yml index 825b784..cbf0a23 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,4 +1,5 @@ -# Host-plane Netx Ops bridge: settings + credentials → netx__* REST tools. +# Host-plane Netx Ops bridge: settings + credentials → connection publish. +# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`). # # After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang # (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d4a5370..482e153 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -6,7 +6,7 @@ One install wires **all of**: | Piece | How you use it | |-------|----------------| -| Host tools | native `netx__*` tools → netx REST (Bearer token) | +| Host tools | Ops-preset-scoped `netx__*` → netx REST (Bearer token); other presets do not see them | | Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) | | Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) | diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 4a62a99..51254fc 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -1,6 +1,6 @@ # Netx Ops tool map -Tools are registered by the host plugin (`dsh-netxops`) via `@deepseek-ai/dsh-tools`. +Tools are registered by the Ops agent preset (`dsh-netxops/tools`) into that preset's tool scope — not the host global layer — so other agent presets do not see them. Model-facing name: `netx__`. diff --git a/lib/index.js b/lib/index.js index 1aa7e21..aa052bd 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,637 +7,26 @@ import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; import * as DshSettings from "@deepseek-ai/dsh-settings"; -// src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; - -// src/netx/http.ts -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function localizePayload(lang, data) { - if (!lang.trim().toLowerCase().startsWith("en")) - return data; - const proto = data.protocol_summary; - if (!Array.isArray(proto)) - return data; - for (const row of proto) { - if (typeof row !== "object" || row === null || Array.isArray(row)) - continue; - const rec = row; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; +// src/netx/runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY] = current; } - return data; + return current; } -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const base = connection.apiUrl.replace(/\/$/, ""); - const headers = { - accept: "application/json" - }; - if (connection.token.trim().length > 0) { - headers.authorization = `Bearer ${connection.token.trim()}`; - } - const langParams = () => { - const lang = connection.lang.trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - const merged = { ...langParams(), ...options.params }; - const url = `${base}${path}${encodeQuery(merged)}`; - const timeoutMs = options.timeoutMs ?? connection.timeoutMs; - const controller = new AbortController; - const timer = setTimeout(() => { - controller.abort(); - }, timeoutMs); - const onOuterAbort = () => { - controller.abort(); - }; - options.signal?.addEventListener("abort", onOuterAbort, { once: true }); - try { - const init = { - method, - headers: options.body === undefined ? headers : { ...headers, "content-type": "application/json" }, - signal: controller.signal - }; - if (options.body !== undefined) - init.body = JSON.stringify(options.body); - const resp = await fetch(url, init); - const text = await resp.text(); - if (!resp.ok) { - return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; - } - const data = text.length > 0 ? JSON.parse(text) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS = 5; -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, [ - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "order_by", - "order" - ]); - let fields = strList(args, "select_fields"); - if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); - if (!neId) { - return { - ok: false, - error: "ne_id_required", - error_code: "ne_id_required", - hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.", - example: { ne_id: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); - if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { - return { - ...out, - hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe." - }; - } - } - return out; -} -async function execManagedNe(client, args, signal) { - const targetsRaw = args.targets; - const neIds = strList(args, "ne_ids"); - const umeNeIds = strList(args, "ume_ne_ids"); - const sharedCommands = strList(args, "commands"); - const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; - if (multi) { - const body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str(row, "ne_id").trim(); - const umeNeId2 = str(row, "ume_ne_id").trim(); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > EXEC_MAX_COMMANDS) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str(args, "ne_id").trim(); - const umeNeId = str(args, "ume_ne_id").trim(); - if (Boolean(neId) === Boolean(umeNeId)) { - return { - ok: false, - error: "exactly_one_of_ne_id_or_ume_ne_id_required", - error_code: "exactly_one_of_ne_id_or_ume_ne_id_required", - hint: "For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server." - }; - } - if (sharedCommands.length === 0) { - return { ok: false, error: "commands_required", error_code: "commands_required" }; - } - if (sharedCommands.length > EXEC_MAX_COMMANDS) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - const body = { - commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = str(args, "from_ume_ne_id").trim(); - const fromMid = str(args, "from_managed_ne_id").trim(); - const toUid = str(args, "to_ume_ne_id").trim(); - const toMid = str(args, "to_managed_ne_id").trim(); - if (Boolean(fromUid) === Boolean(fromMid)) { - return { ok: false, error: "exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required" }; - } - if (Boolean(toUid) === Boolean(toMid)) { - return { ok: false, error: "exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required" }; - } - let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} - -// src/netx/tools.ts -var str2 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num2 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool2 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -function renderJson(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; -} -var jsonOut = { - schema: { type: "json" }, - render: renderJson -}; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ - name, - description, - parameters, - output: jsonOut, - timeoutMs, - isConcurrencySafe: () => true, - async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - if (result.ok === false) { - throw new Error(JSON.stringify(result)); - } - return result; - } - }); -} -function registerNetxTools(ctx, connection) { - const client = createNetxClient({ - apiUrl: connection.apiUrl, - token: connection.token, - lang: connection.lang, - timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000) - }); - const getClient = () => client; - const t = connection.toolCallTimeoutMs; - const disposers = [ - ctx.tools.register(tool("netx__queryUmeAlarms", "Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { - severity: str2(), - ne_id: str2("Filter only; do not show UUID to users"), - host_name: str2("Filter by NE host_name"), - ne_name: str2("Legacy alias mapped to keyword"), - keyword: str2("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str2("ISO time; filters last_seen_at >="), - time_to: str2("ISO time; filters last_seen_at <="), - page: num2(), - page_size: num2() - }, queryUmeAlarms, getClient, t)), - ctx.tools.register(tool("netx__aggregateUmeAlarms", "Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { - severity: str2("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num2("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool2("Omit missing host_name from by_ne."), - time_from: str2(), - time_to: str2(), - group_by: str2("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str2(), - is_cleared: str2(), - ne_id: str2(), - event_type: str2(), - keyword: str2(), - limit: num2() - }, aggregateUmeAlarms, getClient, t)), - ctx.tools.register(tool("netx__runUmeDiagnostics", "UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t)), - ctx.tools.register(tool("netx__queryUmeNeInventory", "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str2(), - page: num2(), - page_size: num2() - }, queryUmeNeInventory, getClient, t)), - ctx.tools.register(tool("netx__getUmeNe", "Get single UME NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "UME inventory ne_id (UUID)." } - }, getUmeNe, getClient, t)), - ctx.tools.register(tool("netx__queryUmeAlarmsRaw", "Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { - severity: str2(), - is_cleared: str2(), - ne_id: str2(), - event_type: str2(), - keyword: str2(), - time_from: str2(), - time_to: str2(), - order_by: str2("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str2("asc | desc"), - select_fields: strArr(), - field_preset: str2("brief | evidence | ne_debug"), - page: num2(), - page_size: num2() - }, queryUmeAlarmsRaw, getClient, t)), - ctx.tools.register(tool("netx__aggregateUmeAlarmsRaw", "Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str2(), - severity: str2(), - is_cleared: str2(), - ne_id: str2(), - event_type: str2(), - keyword: str2(), - time_from: str2(), - time_to: str2(), - exclude_missing_host: bool2(), - limit: num2() - }, aggregateUmeAlarmsRaw, getClient, t)), - ctx.tools.register(tool("netx__listUmeAlarmFields", "List available fields for UME raw alarm queries.", {}, listUmeAlarmFields, getClient, t)), - ctx.tools.register(tool("netx__sqlQueryUme", "Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num2(), - statement_timeout_ms: num2() - }, sqlQueryUme, getClient, t)), - ctx.tools.register(tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str2(), - vendor: str2(), - connect_status: str2("unknown | testing | pass | fail"), - page: num2(), - page_size: num2() - }, listManagedNe, getClient, t)), - ctx.tools.register(tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.", { - ne_id: str2("Managed NE id"), - managed_ne_id: str2("Alias for ne_id"), - id: str2("Alias for ne_id") - }, getManagedNe, getClient, t)), - ctx.tools.register(tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { - ne_id: str2(), - ume_ne_id: str2(), - ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), - ume_ne_ids: strArr("UME inventory ne_ids for concurrent batch (shared commands)."), - targets: { - type: "array", - description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str2(), - ume_ne_id: str2(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num2("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num2("Parallel NEs for batch mode (1–8, default 4)."), - async: bool2("oclaw-only async hint; ignored by native REST client.") - }, execManagedNe, getClient, Math.max(t, 300000))), - ctx.tools.register(tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str2("managed | ume | all"), - keyword: str2(), - page: num2(), - page_size: num2() - }, listCliTargets, getClient, t)), - ctx.tools.register(tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { - from_ume_ne_id: str2(), - from_managed_ne_id: str2(), - to_ume_ne_id: str2(), - to_managed_ne_id: str2(), - max_paths: num2(), - max_hops: num2(), - layer: str2(), - detail: str2("summary | full") - }, findTopologyPaths, getClient, t)) - ]; - return () => { - for (const dispose of disposers) - dispose(); - }; +function publishNetxConnection(next) { + const state = store(); + state.connection = next; + for (const listener of state.listeners) + listener(); } // src/index.ts var name = "netxops"; -var inject = ["tools"]; var NETXOPS_SETTINGS_NAMESPACE = "netxops"; var NETXOPS_PRESET_ID = "netxops"; var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; @@ -698,56 +87,48 @@ function installNetxopsSettings(ctx, entry, hooks) { } function apply(ctx, config = Config({})) { let source = () => config; - let unregister; - let remounting = Promise.resolve(); + let publishing = Promise.resolve(); let generation = 0; if (config.installAgentPreset) { ensureAgentPresetInstalled(ctx.logger); } - const remount = () => { - remounting = remounting.then(async () => { + const publish = () => { + publishing = publishing.then(async () => { const gen = ++generation; - unregister?.(); - unregister = undefined; - if (gen !== generation) - return; const current = source(); const token = await resolveToken(ctx, current.tokenCredentialRef); if (gen !== generation) return; - unregister = registerNetxTools(ctx, { + publishNetxConnection({ apiUrl: current.apiUrl.replace(/\/$/, ""), token, lang: current.lang, toolCallTimeoutMs: current.toolCallTimeoutMs }); - ctx.logger.info("netxops: registered netx__* REST tools → %s", current.apiUrl.replace(/\/$/, "")); + ctx.logger.info("netxops: published connection → %s", current.apiUrl.replace(/\/$/, "")); }).catch((error) => { - ctx.logger.error("netxops: remount error: %s", error); + ctx.logger.error("netxops: connection publish error: %s", error); }); }; - remount(); + publish(); installNetxopsSettings(ctx, config, { setSource: (current) => { source = current; }, onChange: () => { - remount(); + publish(); } }); ctx.on("credentials/reference-updated", (ref) => { if (String(ref) === source().tokenCredentialRef) - remount(); + publish(); }); ctx.effect(() => () => { generation += 1; - unregister?.(); - unregister = undefined; - }, "netxops: dispose netx tools"); + }, "netxops: dispose host bridge"); } export { name, - inject, ensureAgentPresetInstalled, apply, NETXOPS_SETTINGS_NAMESPACE, diff --git a/package.json b/package.json index 5f04975..831acd7 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,21 @@ { "name": "dsh-netxops", - "version": "0.1.12", - "description": "DeepSeek Harness Netx Ops: native netx__* REST tools + Plugins settings card + agent preset", + "version": "0.1.13", + "description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + Plugins settings card + agent preset", "license": "MIT", "type": "module", "private": false, "main": "./lib/index.js", "exports": { ".": "./lib/index.js", + "./tools": "./lib/agent-tools.js", "./client": "./lib/client.js", "./package.json": "./package.json" }, "files": [ "src/", "lib/index.js", + "lib/agent-tools.js", "lib/client.js", "presets/", "cordis.patch.yml", diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index deceb78..ec71f1a 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -1,6 +1,6 @@ # Netx Ops agent preset — UME alarms / NE inventory / managed CLI. -# Host keeps registries, sandbox, model route; this file owns persona + skills. -# netx__* tools are registered on the HOST by package `dsh-netxops`. +# Host keeps registries, sandbox, model route, settings; this file owns +# persona, skills, and Ops-scoped `netx__*` tools. # ── identity ──────────────────────────────────────────────────────────────── @@ -64,6 +64,9 @@ - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' -# netx__* tools are mounted on the HOST by package `dsh-netxops` (cordis.patch.yml). -# The same package copies this preset into `$DSH_HOME/.agent-presets/netxops` +# Ops-only tools: register into this preset's tool scope (not the host global layer). +- id: netxops-tools + name: dsh-netxops/tools + +# Host package `dsh-netxops` copies this preset into `$DSH_HOME/.agent-presets/netxops` # on activate — no manual link-preset script for normal installs. diff --git a/scripts/build-host.mjs b/scripts/build-host.mjs index 2c1f5e2..b43ee3a 100644 --- a/scripts/build-host.mjs +++ b/scripts/build-host.mjs @@ -1,15 +1,14 @@ /** - * Build lib/index.js (host Cordis plugin) for installs under node_modules. + * Build lib/index.js (host) and lib/agent-tools.js (Ops preset tool mount). * Node refuses to strip TypeScript inside node_modules, so GitHub/npm installs * must ship prebuilt JS. Usage: bun run scripts/build-host.mjs */ import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..') -const entry = join(root, 'src/index.ts') -const outFile = join(root, 'lib', 'index.js') +const outDir = join(root, 'lib') const external = [ '@deepseek-ai/cordis', @@ -19,21 +18,29 @@ const external = [ '@deepseek-ai/dsh-tools', ] -const result = await Bun.build({ - entrypoints: [entry], - target: 'node', - format: 'esm', - sourcemap: 'none', - minify: false, - external, -}) +const entries = [ + { entry: join(root, 'src/index.ts'), out: join(outDir, 'index.js') }, + { entry: join(root, 'src/agent-tools.ts'), out: join(outDir, 'agent-tools.js') }, +] -if (!result.success) { - console.error(result.logs) - throw new Error('Bun.build host failed') +mkdirSync(outDir, { recursive: true }) + +for (const { entry, out } of entries) { + const result = await Bun.build({ + entrypoints: [entry], + target: 'node', + format: 'esm', + sourcemap: 'none', + minify: false, + external, + }) + + if (!result.success) { + console.error(result.logs) + throw new Error(`Bun.build host failed for ${basename(entry)}`) + } + + const text = await result.outputs[0].text() + writeFileSync(out, text, 'utf8') + console.log(`wrote ${out} (${text.length} bytes)`) } - -const text = await result.outputs[0].text() -mkdirSync(dirname(outFile), { recursive: true }) -writeFileSync(outFile, text, 'utf8') -console.log(`wrote ${outFile} (${text.length} bytes)`) diff --git a/src/agent-tools.ts b/src/agent-tools.ts new file mode 100644 index 0000000..cfde2c3 --- /dev/null +++ b/src/agent-tools.ts @@ -0,0 +1,45 @@ +/** + * Agent-plane Netx Ops tools: register `netx__*` into the calling context's + * tool scope (the Netx Ops preset standing mount), so other presets do not see them. + * + * @module dsh-netxops/tools + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-tools' +import { getNetxConnection, watchNetxConnection } from './netx/runtime.ts' +import { registerNetxTools } from './netx/tools.ts' + +/** Cordis plugin name. */ +export const name = 'netxops-tools' + +/** Tool registry must exist in this (preset-scoped) context. */ +export const inject = ['tools'] + +/** + * Mount netx REST tools for the Netx Ops agent preset only. + */ +export function apply(ctx: Context): void { + let unregister: (() => void) | undefined + + const remount = (): void => { + unregister?.() + unregister = undefined + const connection = getNetxConnection() + if (connection === undefined) { + ctx.logger.warn('netxops-tools: no connection yet — waiting for host settings bridge') + return + } + unregister = registerNetxTools(ctx, connection) + ctx.logger.info('netxops-tools: registered netx__* for Ops preset → %s', connection.apiUrl) + } + + remount() + const stopWatch = watchNetxConnection(() => { remount() }) + + ctx.effect(() => () => { + stopWatch() + unregister?.() + unregister = undefined + }, 'netxops-tools: dispose') +} diff --git a/src/index.ts b/src/index.ts index 1327e2c..7ee8d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ /** * Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN) - * drive native `netx__*` tools that call the netx REST API directly. + * publish a connection snapshot; the Ops preset mounts `netx__*` into its own + * tool scope (`dsh-netxops/tools`) so other agents do not see them. * * On activate, the agent preset + skills are copied into * `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough @@ -19,15 +20,11 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' import type {} from '@deepseek-ai/dsh-credentials' import * as DshSettings from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-settings' -import type {} from '@deepseek-ai/dsh-tools' -import { registerNetxTools } from './netx/tools.ts' +import { publishNetxConnection } from './netx/runtime.ts' /** Cordis plugin name. */ export const name = 'netxops' -/** Tool registry must exist to register `netx__*` tools. */ -export const inject = ['tools'] - /** Settings / composition namespace (Plugins page join key). */ export const NETXOPS_SETTINGS_NAMESPACE = 'netxops' @@ -145,59 +142,52 @@ function installNetxopsSettings( } /** - * Apply the Netx Ops host bridge. + * Apply the Netx Ops host bridge (settings + connection publish; tools live on the preset). */ export function apply(ctx: Context, config: Config = Config({})): void { let source: () => Config = () => config - let unregister: (() => void) | undefined - let remounting: Promise = Promise.resolve() + let publishing: Promise = Promise.resolve() let generation = 0 if (config.installAgentPreset) { ensureAgentPresetInstalled(ctx.logger) } - const remount = (): void => { - remounting = remounting.then(async () => { + const publish = (): void => { + publishing = publishing.then(async () => { const gen = ++generation - unregister?.() - unregister = undefined - if (gen !== generation) return - const current = source() const token = await resolveToken(ctx, current.tokenCredentialRef) if (gen !== generation) return - unregister = registerNetxTools(ctx, { + publishNetxConnection({ apiUrl: current.apiUrl.replace(/\/$/, ''), token, lang: current.lang, toolCallTimeoutMs: current.toolCallTimeoutMs, }) - ctx.logger.info('netxops: registered netx__* REST tools → %s', current.apiUrl.replace(/\/$/, '')) + ctx.logger.info('netxops: published connection → %s', current.apiUrl.replace(/\/$/, '')) }).catch((error) => { - ctx.logger.error('netxops: remount error: %s', error) + ctx.logger.error('netxops: connection publish error: %s', error) }) } - remount() + publish() installNetxopsSettings(ctx, config, { setSource: (current) => { source = current }, onChange: () => { - remount() + publish() }, }) ctx.on('credentials/reference-updated', (ref) => { - if (String(ref) === source().tokenCredentialRef) remount() + if (String(ref) === source().tokenCredentialRef) publish() }) ctx.effect(() => () => { generation += 1 - unregister?.() - unregister = undefined - }, 'netxops: dispose netx tools') + }, 'netxops: dispose host bridge') } diff --git a/src/netx/runtime.ts b/src/netx/runtime.ts new file mode 100644 index 0000000..5502cf2 --- /dev/null +++ b/src/netx/runtime.ts @@ -0,0 +1,54 @@ +/** + * Process-local Netx Ops connection snapshot shared between the host settings + * bridge and the Ops-preset-scoped tool plugin. + * + * Uses `Symbol.for` on `globalThis` so host + agent-tools bundles share one store + * even when Bun emits them as separate ESM files. + */ + +import type { NetxToolConnection } from './tools.ts' + +type Listener = () => void + +interface Store { + connection: NetxToolConnection | undefined + listeners: Set +} + +const STORE_KEY = Symbol.for('dsh-netxops.connection-store') + +function store(): Store { + const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store } + let current = root[STORE_KEY] + if (current === undefined) { + current = { connection: undefined, listeners: new Set() } + root[STORE_KEY] = current + } + return current +} + +/** + * Publish the latest API URL / token / lang / timeout for Ops tool mounts. + * @param next - connection used by the next `registerNetxTools` call. + */ +export function publishNetxConnection(next: NetxToolConnection): void { + const state = store() + state.connection = next + for (const listener of state.listeners) listener() +} + +/** @returns the last published connection, if any. */ +export function getNetxConnection(): NetxToolConnection | undefined { + return store().connection +} + +/** + * Subscribe to connection publishes (settings / credential remounts). + * @param listener - called synchronously after each publish. + * @returns disposer. + */ +export function watchNetxConnection(listener: Listener): () => void { + const state = store() + state.listeners.add(listener) + return () => { state.listeners.delete(listener) } +}