diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 34c339d..c54e1f1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -44,6 +44,7 @@ bun run link:peers | `Cannot find package '@deepseek-ai/schemastery'` / **netxops 未运行** | 未跑 `link:peers` | | **web boot: dsh-netxops failed** | peers 版本错乱(例如链了 Desktop 的 schemastery 3.18.4)→ 重跑 `bun run link:peers` 再重启 | | `pending (waiting for service: settingsScope)` → 会话无 `netx__*` / 无 `netx-ops` | 旧客户端仍硬/软等待已删除的 `settingsScope`(DSH 0.2);用 **≥0.1.50** 的 tgz 重装并完全重启 Desktop,勿用脏的 `link:` / 混锁 `.modules.yaml` | +| 日志 `no connection yet` / 会话无 `netx__*`,且 `profiles/*/cordis.yml` 只有 `preset-netxops`、**没有** `name: dsh-netxops` | 脏重装后 bundle 宿主行丢失、预设通道自愈不同步。**≥0.1.53** 会在预设 tools 启动时把宿主块写回 `cordis.patch.yml`;看日志 `host bridge … MISSING … wrote it back` 后**完全退出再开** Desktop/`dsh web`。仍不行则 `dsh plugin remove dsh-netxops` 再 `add` | 改 Host/Client 后照常 `bun run bundle`,一般不用重装;换机器或清过本仓库 `node_modules` 才需再 `link:peers`。 diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index 11b938b..cb35e8b 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } +// src/netx/heal-profile-entry.ts +import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { homedir } from "node:os"; +import { join as join5 } from "node:path"; +var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; +var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); +var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + toolCallTimeoutMs: 120000, + installAgentPreset: true, + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: true, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: true, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}); +function resolveDshHome() { + const fromEnv = process.env.DSH_HOME?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) + return fromEnv; + return join5(homedir(), ".dsh"); +} +function managedLineMask(lines) { + const skip = lines.map(() => false); + let inside = false; + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + if (line.includes(DESKTOP_PATCH_BEGIN)) + inside = true; + if (inside) + skip[i] = true; + if (line.includes(DESKTOP_PATCH_END)) + inside = false; + } + return skip; +} +function parseScalar(raw) { + const text = raw.trim(); + if (text === "true") + return true; + if (text === "false") + return false; + if (/^-?\d+$/.test(text)) + return Number(text); + if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} +function formatScalar(value) { + if (typeof value === "boolean" || typeof value === "number") + return String(value); + if (value === "") + return "''"; + if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) { + return JSON.stringify(value); + } + return value; +} +function findHostEntry(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (lines[i] !== "- id: netxops") + continue; + let end = i + 1; + while (end < lines.length) { + const line = lines[end]; + if (skip[end]) + break; + if (/^- id:/.test(line)) + break; + end += 1; + } + while (end > i + 1 && lines[end - 1].trim() === "") + end -= 1; + let disabled; + let name; + const config = {}; + let inConfig = false; + for (let j = i + 1;j < end; j += 1) { + const line = lines[j]; + const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line); + if (disabledMatch) { + disabled = parseScalar(disabledMatch[1]) === true; + inConfig = false; + continue; + } + const nameMatch = /^ name:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + name = String(parseScalar(nameMatch[1])); + inConfig = false; + continue; + } + if (/^ config:\s*$/.test(line)) { + inConfig = true; + continue; + } + if (inConfig) { + const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line); + if (cfg) { + config[cfg[1]] = parseScalar(cfg[2]); + continue; + } + if (/^ \S/.test(line)) + inConfig = false; + } + } + return { start: i, end, disabled, name, config }; + } + return null; +} +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} +function needsHeal(entry) { + if (entry.name !== "dsh-netxops") + return true; + for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { + if (!Object.hasOwn(entry.config, key)) + return true; + } + return false; +} +function renderHostEntry(entry) { + const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }; + const out = [ + "- id: netxops", + " name: dsh-netxops" + ]; + if (entry.disabled === true) + out.push(" disabled: true"); + else + out.push(" disabled: false"); + out.push(" config:"); + for (const [key, value] of Object.entries(config)) { + out.push(` ${key}: ${formatScalar(value)}`); + } + return out; +} +function ensureNetxopsHostInProfilePatches(logger) { + let rewritten = 0; + let inserted = 0; + for (const profileName of ["desktop", "web"]) { + const profileDir = join5(resolveDshHome(), "profiles", profileName); + const patchPath = join5(profileDir, "cordis.patch.yml"); + if (!existsSync4(profileDir)) + continue; + const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : ""; + const nl = raw.includes(`\r +`) ? `\r +` : ` +`; + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` +`).split(` +`) : []; + const skip = managedLineMask(lines); + const entry = findHostEntry(lines, skip); + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) + continue; + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) + continue; + writeFileSync2(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } + } + return { rewritten, inserted }; +} +function tryRecoverMissingHostBridge(logger, pluginName) { + const root = globalThis; + if (root[HOST_HEAL_ONCE] === true) + return; + root[HOST_HEAL_ONCE] = true; + try { + const result = ensureNetxopsHostInProfilePatches(logger); + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName); + } + } catch (error) { + logger.warn("%s: host-bridge recover failed: %s", pluginName, error); + } +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { - ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + tryRecoverMissingHostBridge(ctx.logger, options.name); return; } const enabled = resolveGroups(); diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index ecf0c45..01b51cc 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } +// src/netx/heal-profile-entry.ts +import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { homedir } from "node:os"; +import { join as join5 } from "node:path"; +var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; +var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); +var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + toolCallTimeoutMs: 120000, + installAgentPreset: true, + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: true, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: true, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}); +function resolveDshHome() { + const fromEnv = process.env.DSH_HOME?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) + return fromEnv; + return join5(homedir(), ".dsh"); +} +function managedLineMask(lines) { + const skip = lines.map(() => false); + let inside = false; + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + if (line.includes(DESKTOP_PATCH_BEGIN)) + inside = true; + if (inside) + skip[i] = true; + if (line.includes(DESKTOP_PATCH_END)) + inside = false; + } + return skip; +} +function parseScalar(raw) { + const text = raw.trim(); + if (text === "true") + return true; + if (text === "false") + return false; + if (/^-?\d+$/.test(text)) + return Number(text); + if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} +function formatScalar(value) { + if (typeof value === "boolean" || typeof value === "number") + return String(value); + if (value === "") + return "''"; + if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) { + return JSON.stringify(value); + } + return value; +} +function findHostEntry(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (lines[i] !== "- id: netxops") + continue; + let end = i + 1; + while (end < lines.length) { + const line = lines[end]; + if (skip[end]) + break; + if (/^- id:/.test(line)) + break; + end += 1; + } + while (end > i + 1 && lines[end - 1].trim() === "") + end -= 1; + let disabled; + let name; + const config = {}; + let inConfig = false; + for (let j = i + 1;j < end; j += 1) { + const line = lines[j]; + const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line); + if (disabledMatch) { + disabled = parseScalar(disabledMatch[1]) === true; + inConfig = false; + continue; + } + const nameMatch = /^ name:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + name = String(parseScalar(nameMatch[1])); + inConfig = false; + continue; + } + if (/^ config:\s*$/.test(line)) { + inConfig = true; + continue; + } + if (inConfig) { + const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line); + if (cfg) { + config[cfg[1]] = parseScalar(cfg[2]); + continue; + } + if (/^ \S/.test(line)) + inConfig = false; + } + } + return { start: i, end, disabled, name, config }; + } + return null; +} +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} +function needsHeal(entry) { + if (entry.name !== "dsh-netxops") + return true; + for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { + if (!Object.hasOwn(entry.config, key)) + return true; + } + return false; +} +function renderHostEntry(entry) { + const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }; + const out = [ + "- id: netxops", + " name: dsh-netxops" + ]; + if (entry.disabled === true) + out.push(" disabled: true"); + else + out.push(" disabled: false"); + out.push(" config:"); + for (const [key, value] of Object.entries(config)) { + out.push(` ${key}: ${formatScalar(value)}`); + } + return out; +} +function ensureNetxopsHostInProfilePatches(logger) { + let rewritten = 0; + let inserted = 0; + for (const profileName of ["desktop", "web"]) { + const profileDir = join5(resolveDshHome(), "profiles", profileName); + const patchPath = join5(profileDir, "cordis.patch.yml"); + if (!existsSync4(profileDir)) + continue; + const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : ""; + const nl = raw.includes(`\r +`) ? `\r +` : ` +`; + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` +`).split(` +`) : []; + const skip = managedLineMask(lines); + const entry = findHostEntry(lines, skip); + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) + continue; + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) + continue; + writeFileSync2(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } + } + return { rewritten, inserted }; +} +function tryRecoverMissingHostBridge(logger, pluginName) { + const root = globalThis; + if (root[HOST_HEAL_ONCE] === true) + return; + root[HOST_HEAL_ONCE] = true; + try { + const result = ensureNetxopsHostInProfilePatches(logger); + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName); + } + } catch (error) { + logger.warn("%s: host-bridge recover failed: %s", pluginName, error); + } +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { - ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + tryRecoverMissingHostBridge(ctx.logger, options.name); return; } const enabled = resolveGroups(); diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 9931e4b..02696ac 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } +// src/netx/heal-profile-entry.ts +import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { homedir } from "node:os"; +import { join as join5 } from "node:path"; +var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; +var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); +var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + toolCallTimeoutMs: 120000, + installAgentPreset: true, + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: true, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: true, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}); +function resolveDshHome() { + const fromEnv = process.env.DSH_HOME?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) + return fromEnv; + return join5(homedir(), ".dsh"); +} +function managedLineMask(lines) { + const skip = lines.map(() => false); + let inside = false; + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + if (line.includes(DESKTOP_PATCH_BEGIN)) + inside = true; + if (inside) + skip[i] = true; + if (line.includes(DESKTOP_PATCH_END)) + inside = false; + } + return skip; +} +function parseScalar(raw) { + const text = raw.trim(); + if (text === "true") + return true; + if (text === "false") + return false; + if (/^-?\d+$/.test(text)) + return Number(text); + if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} +function formatScalar(value) { + if (typeof value === "boolean" || typeof value === "number") + return String(value); + if (value === "") + return "''"; + if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) { + return JSON.stringify(value); + } + return value; +} +function findHostEntry(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (lines[i] !== "- id: netxops") + continue; + let end = i + 1; + while (end < lines.length) { + const line = lines[end]; + if (skip[end]) + break; + if (/^- id:/.test(line)) + break; + end += 1; + } + while (end > i + 1 && lines[end - 1].trim() === "") + end -= 1; + let disabled; + let name; + const config = {}; + let inConfig = false; + for (let j = i + 1;j < end; j += 1) { + const line = lines[j]; + const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line); + if (disabledMatch) { + disabled = parseScalar(disabledMatch[1]) === true; + inConfig = false; + continue; + } + const nameMatch = /^ name:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + name = String(parseScalar(nameMatch[1])); + inConfig = false; + continue; + } + if (/^ config:\s*$/.test(line)) { + inConfig = true; + continue; + } + if (inConfig) { + const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line); + if (cfg) { + config[cfg[1]] = parseScalar(cfg[2]); + continue; + } + if (/^ \S/.test(line)) + inConfig = false; + } + } + return { start: i, end, disabled, name, config }; + } + return null; +} +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} +function needsHeal(entry) { + if (entry.name !== "dsh-netxops") + return true; + for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { + if (!Object.hasOwn(entry.config, key)) + return true; + } + return false; +} +function renderHostEntry(entry) { + const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }; + const out = [ + "- id: netxops", + " name: dsh-netxops" + ]; + if (entry.disabled === true) + out.push(" disabled: true"); + else + out.push(" disabled: false"); + out.push(" config:"); + for (const [key, value] of Object.entries(config)) { + out.push(` ${key}: ${formatScalar(value)}`); + } + return out; +} +function ensureNetxopsHostInProfilePatches(logger) { + let rewritten = 0; + let inserted = 0; + for (const profileName of ["desktop", "web"]) { + const profileDir = join5(resolveDshHome(), "profiles", profileName); + const patchPath = join5(profileDir, "cordis.patch.yml"); + if (!existsSync4(profileDir)) + continue; + const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : ""; + const nl = raw.includes(`\r +`) ? `\r +` : ` +`; + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` +`).split(` +`) : []; + const skip = managedLineMask(lines); + const entry = findHostEntry(lines, skip); + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) + continue; + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) + continue; + writeFileSync2(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } + } + return { rewritten, inserted }; +} +function tryRecoverMissingHostBridge(logger, pluginName) { + const root = globalThis; + if (root[HOST_HEAL_ONCE] === true) + return; + root[HOST_HEAL_ONCE] = true; + try { + const result = ensureNetxopsHostInProfilePatches(logger); + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName); + } + } catch (error) { + logger.warn("%s: host-bridge recover failed: %s", pluginName, error); + } +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { - ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + tryRecoverMissingHostBridge(ctx.logger, options.name); return; } const enabled = resolveGroups(); diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 547dd8f..06e47af 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } +// src/netx/heal-profile-entry.ts +import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { homedir } from "node:os"; +import { join as join5 } from "node:path"; +var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; +var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); +var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + toolCallTimeoutMs: 120000, + installAgentPreset: true, + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: true, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: true, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}); +function resolveDshHome() { + const fromEnv = process.env.DSH_HOME?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) + return fromEnv; + return join5(homedir(), ".dsh"); +} +function managedLineMask(lines) { + const skip = lines.map(() => false); + let inside = false; + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + if (line.includes(DESKTOP_PATCH_BEGIN)) + inside = true; + if (inside) + skip[i] = true; + if (line.includes(DESKTOP_PATCH_END)) + inside = false; + } + return skip; +} +function parseScalar(raw) { + const text = raw.trim(); + if (text === "true") + return true; + if (text === "false") + return false; + if (/^-?\d+$/.test(text)) + return Number(text); + if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} +function formatScalar(value) { + if (typeof value === "boolean" || typeof value === "number") + return String(value); + if (value === "") + return "''"; + if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) { + return JSON.stringify(value); + } + return value; +} +function findHostEntry(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (lines[i] !== "- id: netxops") + continue; + let end = i + 1; + while (end < lines.length) { + const line = lines[end]; + if (skip[end]) + break; + if (/^- id:/.test(line)) + break; + end += 1; + } + while (end > i + 1 && lines[end - 1].trim() === "") + end -= 1; + let disabled; + let name; + const config = {}; + let inConfig = false; + for (let j = i + 1;j < end; j += 1) { + const line = lines[j]; + const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line); + if (disabledMatch) { + disabled = parseScalar(disabledMatch[1]) === true; + inConfig = false; + continue; + } + const nameMatch = /^ name:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + name = String(parseScalar(nameMatch[1])); + inConfig = false; + continue; + } + if (/^ config:\s*$/.test(line)) { + inConfig = true; + continue; + } + if (inConfig) { + const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line); + if (cfg) { + config[cfg[1]] = parseScalar(cfg[2]); + continue; + } + if (/^ \S/.test(line)) + inConfig = false; + } + } + return { start: i, end, disabled, name, config }; + } + return null; +} +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} +function needsHeal(entry) { + if (entry.name !== "dsh-netxops") + return true; + for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { + if (!Object.hasOwn(entry.config, key)) + return true; + } + return false; +} +function renderHostEntry(entry) { + const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }; + const out = [ + "- id: netxops", + " name: dsh-netxops" + ]; + if (entry.disabled === true) + out.push(" disabled: true"); + else + out.push(" disabled: false"); + out.push(" config:"); + for (const [key, value] of Object.entries(config)) { + out.push(` ${key}: ${formatScalar(value)}`); + } + return out; +} +function ensureNetxopsHostInProfilePatches(logger) { + let rewritten = 0; + let inserted = 0; + for (const profileName of ["desktop", "web"]) { + const profileDir = join5(resolveDshHome(), "profiles", profileName); + const patchPath = join5(profileDir, "cordis.patch.yml"); + if (!existsSync4(profileDir)) + continue; + const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : ""; + const nl = raw.includes(`\r +`) ? `\r +` : ` +`; + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` +`).split(` +`) : []; + const skip = managedLineMask(lines); + const entry = findHostEntry(lines, skip); + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) + continue; + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) + continue; + writeFileSync2(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } + } + return { rewritten, inserted }; +} +function tryRecoverMissingHostBridge(logger, pluginName) { + const root = globalThis; + if (root[HOST_HEAL_ONCE] === true) + return; + root[HOST_HEAL_ONCE] = true; + try { + const result = ensureNetxopsHostInProfilePatches(logger); + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName); + } + } catch (error) { + logger.warn("%s: host-bridge recover failed: %s", pluginName, error); + } +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { - ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + tryRecoverMissingHostBridge(ctx.logger, options.name); return; } const enabled = resolveGroups(); diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index e54247b..729ba5d 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } +// src/netx/heal-profile-entry.ts +import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { homedir } from "node:os"; +import { join as join5 } from "node:path"; +var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; +var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); +var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + toolCallTimeoutMs: 120000, + installAgentPreset: true, + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: true, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: true, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}); +function resolveDshHome() { + const fromEnv = process.env.DSH_HOME?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) + return fromEnv; + return join5(homedir(), ".dsh"); +} +function managedLineMask(lines) { + const skip = lines.map(() => false); + let inside = false; + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + if (line.includes(DESKTOP_PATCH_BEGIN)) + inside = true; + if (inside) + skip[i] = true; + if (line.includes(DESKTOP_PATCH_END)) + inside = false; + } + return skip; +} +function parseScalar(raw) { + const text = raw.trim(); + if (text === "true") + return true; + if (text === "false") + return false; + if (/^-?\d+$/.test(text)) + return Number(text); + if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} +function formatScalar(value) { + if (typeof value === "boolean" || typeof value === "number") + return String(value); + if (value === "") + return "''"; + if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) { + return JSON.stringify(value); + } + return value; +} +function findHostEntry(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (lines[i] !== "- id: netxops") + continue; + let end = i + 1; + while (end < lines.length) { + const line = lines[end]; + if (skip[end]) + break; + if (/^- id:/.test(line)) + break; + end += 1; + } + while (end > i + 1 && lines[end - 1].trim() === "") + end -= 1; + let disabled; + let name; + const config = {}; + let inConfig = false; + for (let j = i + 1;j < end; j += 1) { + const line = lines[j]; + const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line); + if (disabledMatch) { + disabled = parseScalar(disabledMatch[1]) === true; + inConfig = false; + continue; + } + const nameMatch = /^ name:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + name = String(parseScalar(nameMatch[1])); + inConfig = false; + continue; + } + if (/^ config:\s*$/.test(line)) { + inConfig = true; + continue; + } + if (inConfig) { + const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line); + if (cfg) { + config[cfg[1]] = parseScalar(cfg[2]); + continue; + } + if (/^ \S/.test(line)) + inConfig = false; + } + } + return { start: i, end, disabled, name, config }; + } + return null; +} +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} +function needsHeal(entry) { + if (entry.name !== "dsh-netxops") + return true; + for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { + if (!Object.hasOwn(entry.config, key)) + return true; + } + return false; +} +function renderHostEntry(entry) { + const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }; + const out = [ + "- id: netxops", + " name: dsh-netxops" + ]; + if (entry.disabled === true) + out.push(" disabled: true"); + else + out.push(" disabled: false"); + out.push(" config:"); + for (const [key, value] of Object.entries(config)) { + out.push(` ${key}: ${formatScalar(value)}`); + } + return out; +} +function ensureNetxopsHostInProfilePatches(logger) { + let rewritten = 0; + let inserted = 0; + for (const profileName of ["desktop", "web"]) { + const profileDir = join5(resolveDshHome(), "profiles", profileName); + const patchPath = join5(profileDir, "cordis.patch.yml"); + if (!existsSync4(profileDir)) + continue; + const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : ""; + const nl = raw.includes(`\r +`) ? `\r +` : ` +`; + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` +`).split(` +`) : []; + const skip = managedLineMask(lines); + const entry = findHostEntry(lines, skip); + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) + continue; + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) + continue; + writeFileSync2(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } + } + return { rewritten, inserted }; +} +function tryRecoverMissingHostBridge(logger, pluginName) { + const root = globalThis; + if (root[HOST_HEAL_ONCE] === true) + return; + root[HOST_HEAL_ONCE] = true; + try { + const result = ensureNetxopsHostInProfilePatches(logger); + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName); + } + } catch (error) { + logger.warn("%s: host-bridge recover failed: %s", pluginName, error); + } +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { - ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + tryRecoverMissingHostBridge(ctx.logger, options.name); return; } const enabled = resolveGroups(); diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 90f6480..4855aff 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1038,6 +1038,244 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } +// src/netx/heal-profile-entry.ts +import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { homedir } from "node:os"; +import { join as join5 } from "node:path"; +var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; +var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); +var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + toolCallTimeoutMs: 120000, + installAgentPreset: true, + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: true, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: true, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}); +function resolveDshHome() { + const fromEnv = process.env.DSH_HOME?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) + return fromEnv; + return join5(homedir(), ".dsh"); +} +function managedLineMask(lines) { + const skip = lines.map(() => false); + let inside = false; + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + if (line.includes(DESKTOP_PATCH_BEGIN)) + inside = true; + if (inside) + skip[i] = true; + if (line.includes(DESKTOP_PATCH_END)) + inside = false; + } + return skip; +} +function parseScalar(raw) { + const text = raw.trim(); + if (text === "true") + return true; + if (text === "false") + return false; + if (/^-?\d+$/.test(text)) + return Number(text); + if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; +} +function formatScalar(value) { + if (typeof value === "boolean" || typeof value === "number") + return String(value); + if (value === "") + return "''"; + if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) { + return JSON.stringify(value); + } + return value; +} +function findHostEntry(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (lines[i] !== "- id: netxops") + continue; + let end = i + 1; + while (end < lines.length) { + const line = lines[end]; + if (skip[end]) + break; + if (/^- id:/.test(line)) + break; + end += 1; + } + while (end > i + 1 && lines[end - 1].trim() === "") + end -= 1; + let disabled; + let name; + const config = {}; + let inConfig = false; + for (let j = i + 1;j < end; j += 1) { + const line = lines[j]; + const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line); + if (disabledMatch) { + disabled = parseScalar(disabledMatch[1]) === true; + inConfig = false; + continue; + } + const nameMatch = /^ name:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + name = String(parseScalar(nameMatch[1])); + inConfig = false; + continue; + } + if (/^ config:\s*$/.test(line)) { + inConfig = true; + continue; + } + if (inConfig) { + const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line); + if (cfg) { + config[cfg[1]] = parseScalar(cfg[2]); + continue; + } + if (/^ \S/.test(line)) + inConfig = false; + } + } + return { start: i, end, disabled, name, config }; + } + return null; +} +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} +function needsHeal(entry) { + if (entry.name !== "dsh-netxops") + return true; + for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { + if (!Object.hasOwn(entry.config, key)) + return true; + } + return false; +} +function renderHostEntry(entry) { + const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }; + const out = [ + "- id: netxops", + " name: dsh-netxops" + ]; + if (entry.disabled === true) + out.push(" disabled: true"); + else + out.push(" disabled: false"); + out.push(" config:"); + for (const [key, value] of Object.entries(config)) { + out.push(` ${key}: ${formatScalar(value)}`); + } + return out; +} +function ensureNetxopsHostInProfilePatches(logger) { + let rewritten = 0; + let inserted = 0; + for (const profileName of ["desktop", "web"]) { + const profileDir = join5(resolveDshHome(), "profiles", profileName); + const patchPath = join5(profileDir, "cordis.patch.yml"); + if (!existsSync4(profileDir)) + continue; + const raw = existsSync4(patchPath) ? readFileSync2(patchPath, "utf8") : ""; + const nl = raw.includes(`\r +`) ? `\r +` : ` +`; + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` +`).split(` +`) : []; + const skip = managedLineMask(lines); + const entry = findHostEntry(lines, skip); + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) + continue; + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) + continue; + writeFileSync2(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } + } + return { rewritten, inserted }; +} +function tryRecoverMissingHostBridge(logger, pluginName) { + const root = globalThis; + if (root[HOST_HEAL_ONCE] === true) + return; + root[HOST_HEAL_ONCE] = true; + try { + const result = ensureNetxopsHostInProfilePatches(logger); + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn("%s: no connection yet — host settings bridge not publishing. " + "Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). " + "Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.", pluginName); + } + } catch (error) { + logger.warn("%s: host-bridge recover failed: %s", pluginName, error); + } +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -3026,7 +3264,7 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { - ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + tryRecoverMissingHostBridge(ctx.logger, options.name); return; } const enabled = resolveGroups(); diff --git a/lib/index.js b/lib/index.js index 60da86d..c928f06 100644 --- a/lib/index.js +++ b/lib/index.js @@ -26,6 +26,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var HOST_HEAL_ONCE = Symbol.for("dsh-netxops.host-bridge-heal-attempted"); var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ apiUrl: "http://127.0.0.1:8890", lang: "zh", @@ -147,6 +148,15 @@ function findHostEntry(lines, skip) { } return null; } +function hostBridgeNamedElsewhere(lines, skip) { + for (let i = 0;i < lines.length; i += 1) { + if (skip[i]) + continue; + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i])) + return true; + } + return false; +} function needsHeal(entry) { if (entry.name !== "dsh-netxops") return true; @@ -164,7 +174,7 @@ function renderHostEntry(entry) { ]; if (entry.disabled === true) out.push(" disabled: true"); - else if (entry.disabled === false) + else out.push(" disabled: false"); out.push(" config:"); for (const [key, value] of Object.entries(config)) { @@ -172,40 +182,66 @@ function renderHostEntry(entry) { } return out; } -function healNetxopsProfileEntries(logger) { +function ensureNetxopsHostInProfilePatches(logger) { let rewritten = 0; + let inserted = 0; for (const profileName of ["desktop", "web"]) { - const patchPath = join(resolveDshHome(), "profiles", profileName, "cordis.patch.yml"); - if (!existsSync(patchPath)) + const profileDir = join(resolveDshHome(), "profiles", profileName); + const patchPath = join(profileDir, "cordis.patch.yml"); + if (!existsSync(profileDir)) continue; - const raw = readFileSync(patchPath, "utf8"); + const raw = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : ""; const nl = raw.includes(`\r `) ? `\r ` : ` `; - const lines = raw.replace(/\r\n/g, ` + const lines = raw.length > 0 ? raw.replace(/\r\n/g, ` `).split(` -`); +`) : []; const skip = managedLineMask(lines); const entry = findHostEntry(lines, skip); - if (entry === null) + let nextLines = null; + let kind = null; + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) + continue; + const rendered = renderHostEntry({ disabled: false, config: {} }); + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)); + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, "", ...lines.slice(beginIdx)]; + } else if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") { + nextLines = [...rendered, ""]; + } else { + const body = [...lines]; + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + nextLines = [...body, "", ...rendered, ""]; + } + kind = "insert"; + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end) + ]; + kind = "rewrite"; + } + if (nextLines === null || kind === null) continue; - if (!needsHeal(entry)) + const normalized = nextLines.join(nl); + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`; + if (withNl === raw) continue; - const rendered = renderHostEntry(entry); - const next = [ - ...lines.slice(0, entry.start), - ...rendered, - ...lines.slice(entry.end) - ].join(nl); - const normalized = next.endsWith(nl) ? next : `${next}${nl}`; - if (normalized === raw) - continue; - writeFileSync(patchPath, normalized, "utf8"); - rewritten += 1; - logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing after heal)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + writeFileSync(patchPath, withNl, "utf8"); + if (kind === "insert") { + inserted += 1; + logger.error("netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — " + "wrote it back. Fully quit and restart %s once so the host publishes the connection " + "(preset-only installs leave netxops-tools waiting forever).", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } else { + rewritten += 1; + logger.info("netxops: healed sparse host entry in profiles/%s/cordis.patch.yml " + "(public toggles hot-reload via settings; restart %s only if tools still missing)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); + } } - return rewritten; + return { rewritten, inserted }; } // src/agent-preset-install.ts @@ -695,7 +731,7 @@ function ensureAgentPresetInstalled(logger) { logger.error("netxops: failed to sync declarative preset into profile patch: %s", error); } try { - healNetxopsProfileEntries(logger); + ensureNetxopsHostInProfilePatches(logger); } catch (error) { logger.error("netxops: failed to heal host profile entry: %s", error); } diff --git a/package.json b/package.json index 1a6a29a..1a4f5b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.52", + "version": "0.1.53", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/src/agent-preset-install.ts b/src/agent-preset-install.ts index 13c0df2..0edb0c8 100644 --- a/src/agent-preset-install.ts +++ b/src/agent-preset-install.ts @@ -27,7 +27,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' -import { healNetxopsProfileEntries } from './netx/heal-profile-entry.ts' +import { ensureNetxopsHostInProfilePatches } from './netx/heal-profile-entry.ts' export const NETXOPS_PRESET_ID = 'netxops' @@ -612,10 +612,10 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void { logger.error('netxops: failed to sync declarative preset into profile patch: %s', error) } try { - // Plugin Manager often leaves `- id: netxops` / `disabled: false` with no - // config; DSH 0.2 replaces the whole Config → capability/public flags never - // apply. Expand missing keys from package defaults on every Host apply. - healNetxopsProfileEntries(logger) + // (1) Sparse `- id: netxops` from Plugin Manager → expand missing config keys. + // (2) Missing host row after dirty reinstall (preset channel self-healed, + // bundle host insert lost) → insert full `name: dsh-netxops` block. + ensureNetxopsHostInProfilePatches(logger) } catch (error) { logger.error('netxops: failed to heal host profile entry: %s', error) } diff --git a/src/netx/group-tools-plugin.ts b/src/netx/group-tools-plugin.ts index 7ea3519..a7e6fe1 100644 --- a/src/netx/group-tools-plugin.ts +++ b/src/netx/group-tools-plugin.ts @@ -14,6 +14,7 @@ import { registerKbContextSkill } from './kb-context-skill.ts' import { registerKbLocalTools } from './kb-local-tools.ts' import { registerKbPackSkills } from './kb-pack-skills.ts' import { getKbContext, watchKbContext } from './kb-runtime.ts' +import { tryRecoverMissingHostBridge } from './heal-profile-entry.ts' import { getNetxConnection, watchNetxConnection } from './runtime.ts' import { registerNetxTools } from './tools.ts' @@ -51,7 +52,9 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt unregisterTools = undefined const connection = getNetxConnection() if (connection === undefined) { - ctx.logger.warn('%s: no connection yet — waiting for host settings bridge', options.name) + // Preset channel can self-heal while the Host bundle row is gone — restore + // `dsh-netxops` into cordis.patch.yml once, then require a full restart. + tryRecoverMissingHostBridge(ctx.logger, options.name) return } const enabled = resolveGroups() diff --git a/src/netx/heal-profile-entry.ts b/src/netx/heal-profile-entry.ts index f169831..2b69937 100644 --- a/src/netx/heal-profile-entry.ts +++ b/src/netx/heal-profile-entry.ts @@ -1,12 +1,16 @@ /** - * Heal sparse Desktop/Web profile overrides for the host `netxops` row. + * Ensure the Host bridge row `dsh-netxops` exists in desktop/web profile patches. * - * Plugin Manager often writes only: - * - id: netxops - * disabled: false - * On DSH 0.2 that override **replaces the whole Config**, so capability / - * public flags never land unless we re-expand the entry. Call this on Host - * apply so every install (not just hand-edited machines) gets a usable row. + * Two independent install channels drift apart after dirty reinstalls: + * - Preset channel: `ensureAgentPresetInstalled` rewrites the managed + * `preset-netxops` block into `cordis.patch.yml` on every Host apply. + * - Host channel: normally comes from the package `cordis.bundle.patch.yml` + * insert, merged only while the bundle stays selected. + * + * When the bundle host insert is lost, Netx Ops preset + `netxops-tools` can + * still load, but `getNetxConnection()` stays undefined forever → no tools. + * Call this from Host apply **and** from the preset tools plugin (chicken-egg + * recovery when only the preset side is still alive). */ import { existsSync, readFileSync, writeFileSync } from 'node:fs' @@ -17,6 +21,8 @@ import type { Context } from '@deepseek-ai/cordis' const DESKTOP_PATCH_BEGIN = '# BEGIN dsh-netxops-preset (managed)' const DESKTOP_PATCH_END = '# END dsh-netxops-preset (managed)' +const HOST_HEAL_ONCE = Symbol.for('dsh-netxops.host-bridge-heal-attempted') + /** Defaults merged into missing keys only — never overwrite user values. */ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly> = Object.freeze({ apiUrl: 'http://127.0.0.1:8890', @@ -45,6 +51,11 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly 0) return fromEnv @@ -143,6 +154,15 @@ function findHostEntry(lines: string[], skip: boolean[]): HostEntry | null { return null } +/** True when any non-managed row already names the host package (insert or id form). */ +function hostBridgeNamedElsewhere(lines: string[], skip: boolean[]): boolean { + for (let i = 0; i < lines.length; i += 1) { + if (skip[i]) continue + if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]!)) return true + } + return false +} + function needsHeal(entry: HostEntry): boolean { if (entry.name !== 'dsh-netxops') return true for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) { @@ -151,14 +171,14 @@ function needsHeal(entry: HostEntry): boolean { return false } -function renderHostEntry(entry: HostEntry): string[] { +function renderHostEntry(entry: Pick): string[] { const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config } const out: string[] = [ '- id: netxops', - " name: dsh-netxops", + ' name: dsh-netxops', ] if (entry.disabled === true) out.push(' disabled: true') - else if (entry.disabled === false) out.push(' disabled: false') + else out.push(' disabled: false') out.push(' config:') for (const [key, value] of Object.entries(config)) { out.push(` ${key}: ${formatScalar(value)}`) @@ -167,38 +187,100 @@ function renderHostEntry(entry: HostEntry): string[] { } /** - * Expand sparse `netxops` host overrides in desktop/web profile patches. - * @returns number of profiles rewritten. + * Insert or expand the Host `dsh-netxops` row in desktop/web `cordis.patch.yml`. + * @returns counts of profiles rewritten (sparse) vs freshly inserted (missing). */ -export function healNetxopsProfileEntries(logger: Context['logger']): number { +export function ensureNetxopsHostInProfilePatches(logger: Context['logger']): HostBridgeHealResult { let rewritten = 0 + let inserted = 0 for (const profileName of ['desktop', 'web'] as const) { - const patchPath = join(resolveDshHome(), 'profiles', profileName, 'cordis.patch.yml') - if (!existsSync(patchPath)) continue - const raw = readFileSync(patchPath, 'utf8') + const profileDir = join(resolveDshHome(), 'profiles', profileName) + const patchPath = join(profileDir, 'cordis.patch.yml') + if (!existsSync(profileDir)) continue + const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '' const nl = raw.includes('\r\n') ? '\r\n' : '\n' - const lines = raw.replace(/\r\n/g, '\n').split('\n') - // Drop trailing empty split artifact for rewrite math; re-join with file nl. + const lines = raw.length > 0 ? raw.replace(/\r\n/g, '\n').split('\n') : [] const skip = managedLineMask(lines) const entry = findHostEntry(lines, skip) - if (entry === null) continue - if (!needsHeal(entry)) continue - const rendered = renderHostEntry(entry) - const next = [ - ...lines.slice(0, entry.start), - ...rendered, - ...lines.slice(entry.end), - ].join(nl) - const normalized = next.endsWith(nl) ? next : `${next}${nl}` - if (normalized === raw) continue - writeFileSync(patchPath, normalized, 'utf8') - rewritten += 1 - logger.info( - 'netxops: healed sparse host entry in profiles/%s/cordis.patch.yml ' - + '(public toggles hot-reload via settings; restart %s only if tools still missing after heal)', - profileName, - profileName === 'desktop' ? 'Desktop' : 'dsh web', - ) + + let nextLines: string[] | null = null + let kind: 'insert' | 'rewrite' | null = null + + if (entry === null) { + if (hostBridgeNamedElsewhere(lines, skip)) continue + const rendered = renderHostEntry({ disabled: false, config: {} }) + const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN)) + if (beginIdx >= 0) { + nextLines = [...lines.slice(0, beginIdx), ...rendered, '', ...lines.slice(beginIdx)] + } else if (lines.length === 0 || (lines.length === 1 && lines[0]!.trim() === '')) { + nextLines = [...rendered, ''] + } else { + const body = [...lines] + while (body.length > 0 && body[body.length - 1]!.trim() === '') body.pop() + nextLines = [...body, '', ...rendered, ''] + } + kind = 'insert' + } else if (needsHeal(entry)) { + nextLines = [ + ...lines.slice(0, entry.start), + ...renderHostEntry(entry), + ...lines.slice(entry.end), + ] + kind = 'rewrite' + } + + if (nextLines === null || kind === null) continue + const normalized = nextLines.join(nl) + const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}` + if (withNl === raw) continue + writeFileSync(patchPath, withNl, 'utf8') + if (kind === 'insert') { + inserted += 1 + logger.error( + 'netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — ' + + 'wrote it back. Fully quit and restart %s once so the host publishes the connection ' + + '(preset-only installs leave netxops-tools waiting forever).', + profileName, + profileName === 'desktop' ? 'Desktop' : 'dsh web', + ) + } else { + rewritten += 1 + logger.info( + 'netxops: healed sparse host entry in profiles/%s/cordis.patch.yml ' + + '(public toggles hot-reload via settings; restart %s only if tools still missing)', + profileName, + profileName === 'desktop' ? 'Desktop' : 'dsh web', + ) + } + } + return { rewritten, inserted } +} + +/** @deprecated Use {@link ensureNetxopsHostInProfilePatches}. */ +export function healNetxopsProfileEntries(logger: Context['logger']): number { + const result = ensureNetxopsHostInProfilePatches(logger) + return result.rewritten + result.inserted +} + +/** + * Preset-side chicken-egg recovery: when tools mount with no connection, try + * once per process to restore the missing Host bridge into the profile patch. + */ +export function tryRecoverMissingHostBridge(logger: Context['logger'], pluginName: string): void { + const root = globalThis as typeof globalThis & { [HOST_HEAL_ONCE]?: boolean } + if (root[HOST_HEAL_ONCE] === true) return + root[HOST_HEAL_ONCE] = true + try { + const result = ensureNetxopsHostInProfilePatches(logger) + if (result.inserted === 0 && result.rewritten === 0) { + logger.warn( + '%s: no connection yet — host settings bridge not publishing. ' + + 'Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). ' + + 'Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.', + pluginName, + ) + } + } catch (error) { + logger.warn('%s: host-bridge recover failed: %s', pluginName, error) } - return rewritten }