mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-19 20:23:19 +08:00
feat(skills): pull group skills from netx /mcp resources, drop bundled copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
277a61894d
commit
fa82f530e9
31 changed files with 2074 additions and 2027 deletions
|
|
@ -4,10 +4,38 @@
|
|||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { registerGroupSkills } from '../src/netx/group-skills.ts'
|
||||
import { replaceSkillsCache } from '../src/netx/skills-cache.ts'
|
||||
|
||||
const OPS_SKILL = `---
|
||||
name: netx-ops
|
||||
description: Ops playbook
|
||||
---
|
||||
Body
|
||||
`
|
||||
|
||||
test('registerGroupSkills appends disabled footnote; skips disabled groups', async (t) => {
|
||||
const previous = process.env.DSH_HOME
|
||||
const home = mkdtempSync(join(tmpdir(), 'netxops-skills-disabled-'))
|
||||
process.env.DSH_HOME = home
|
||||
t.after(() => {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
})
|
||||
|
||||
const apiUrl = 'http://127.0.0.1:8890'
|
||||
const root = replaceSkillsCache(apiUrl, [{
|
||||
group: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
}])
|
||||
|
||||
test('registerGroupSkills appends disabled footnote; skips disabled groups', async () => {
|
||||
/** @type {Array<{ name: string, content: string }>} */
|
||||
const registered = []
|
||||
const ctx = {
|
||||
|
|
@ -21,6 +49,7 @@ test('registerGroupSkills appends disabled footnote; skips disabled groups', asy
|
|||
}
|
||||
|
||||
const dispose = await registerGroupSkills(ctx, ['ops'], 'test', {
|
||||
skillsRoot: root,
|
||||
disabledByGroup: { ops: ['netx__execManagedNe'] },
|
||||
lang: 'zh',
|
||||
})
|
||||
|
|
@ -30,6 +59,7 @@ test('registerGroupSkills appends disabled footnote; skips disabled groups', asy
|
|||
|
||||
registered.length = 0
|
||||
const dispose2 = await registerGroupSkills(ctx, [], 'test', {
|
||||
skillsRoot: root,
|
||||
disabledByGroup: { ops: ['netx__execManagedNe'] },
|
||||
})
|
||||
assert.equal(registered.length, 0)
|
||||
|
|
@ -37,6 +67,7 @@ test('registerGroupSkills appends disabled footnote; skips disabled groups', asy
|
|||
|
||||
registered.length = 0
|
||||
const dispose3 = await registerGroupSkills(ctx, ['ops'], 'test', {
|
||||
skillsRoot: root,
|
||||
disabledByGroup: { ops: ['netx__execManagedNe'] },
|
||||
lang: 'en',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,32 +1,350 @@
|
|||
/**
|
||||
* Group skill loader: one capability group ↔ one skill dir.
|
||||
* Group skills: MCP resources → disk cache → registerGroupSkills.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { opsSkillsRoot } from '../src/netx/group-skills.ts'
|
||||
import { SKILL_DIR_BY_GROUP } from '../src/netx/capability-groups.ts'
|
||||
import { registerGroupSkills } from '../src/netx/group-skills.ts'
|
||||
import { refreshMcpCatalog, resetMcpCatalog } from '../src/netx/mcp-store.ts'
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import {
|
||||
hasSkillsCache,
|
||||
replaceSkillsCache,
|
||||
skillsCacheDir,
|
||||
} from '../src/netx/skills-cache.ts'
|
||||
|
||||
test('opsSkillsRoot points at ops / topology playbook dirs', () => {
|
||||
const root = opsSkillsRoot()
|
||||
assert.ok(existsSync(root), root)
|
||||
for (const dir of Object.values(SKILL_DIR_BY_GROUP)) {
|
||||
assert.ok(existsSync(join(root, dir)), join(root, dir))
|
||||
const API = 'http://127.0.0.1:18890'
|
||||
const OPS_SKILL = `---
|
||||
name: netx-ops
|
||||
description: Ops playbook for tests
|
||||
---
|
||||
# Ops
|
||||
|
||||
Use queryNmsAlarms.
|
||||
`
|
||||
|
||||
const OPS_REF = '# Reference\n\nExtra detail.\n'
|
||||
const TOPO_SKILL = `---
|
||||
name: netx-topology
|
||||
description: Topology playbook
|
||||
---
|
||||
# Topology
|
||||
`
|
||||
|
||||
function skillResources(files) {
|
||||
return files.map((f) => ({
|
||||
uri: `netx://skills/${f.groupDir}/${f.skill}/${f.relPath}`,
|
||||
name: f.relPath,
|
||||
mimeType: 'text/markdown',
|
||||
...(f.relPath === 'SKILL.md' ? { description: f.description || f.skill } : {}),
|
||||
_meta: {
|
||||
netx: {
|
||||
group: f.group,
|
||||
skill: f.skill,
|
||||
entry: f.relPath === 'SKILL.md',
|
||||
},
|
||||
},
|
||||
text: f.text,
|
||||
}))
|
||||
}
|
||||
|
||||
function startMockMcp({
|
||||
tools = [{ name: 'queryNmsAlarms', description: 'alarms', readOnlyHint: true, group: 'ops' }],
|
||||
resources = null,
|
||||
failResources = false,
|
||||
token = 'secret',
|
||||
} = {}) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
const auth = req.headers.authorization ?? ''
|
||||
if (token && auth !== `Bearer ${token}`) {
|
||||
res.writeHead(401)
|
||||
res.end('unauthorized')
|
||||
return
|
||||
}
|
||||
const reply = (result) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id ?? 1, result }))
|
||||
}
|
||||
if (body.method === 'initialize') {
|
||||
reply({
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: resources === null && !failResources
|
||||
? { tools: {} }
|
||||
: { tools: {}, resources: {} },
|
||||
serverInfo: { name: 'mock-netx', version: '0.0.0' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'notifications/initialized') {
|
||||
res.writeHead(202)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (body.method === 'tools/list') {
|
||||
reply({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: { readOnlyHint: t.readOnlyHint !== false },
|
||||
_meta: { netx: { group: t.group } },
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'resources/list') {
|
||||
if (failResources) {
|
||||
res.writeHead(503)
|
||||
res.end('resources down')
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
reply({
|
||||
resources: list.map(({ text: _t, ...rest }) => rest),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'resources/read') {
|
||||
if (failResources) {
|
||||
res.writeHead(503)
|
||||
res.end('resources down')
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
const hit = list.find((r) => r.uri === body.params?.uri)
|
||||
if (!hit) {
|
||||
res.writeHead(400)
|
||||
res.end('missing')
|
||||
return
|
||||
}
|
||||
reply({
|
||||
contents: [{ uri: hit.uri, mimeType: 'text/markdown', text: hit.text }],
|
||||
})
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end('unknown method')
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address()
|
||||
resolve({
|
||||
server,
|
||||
apiUrl: `http://127.0.0.1:${port}`,
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function fakeSkillsCtx() {
|
||||
/** @type {Array<{ name: string, content: string, path?: string, resourceBase?: { path: string } }>} */
|
||||
const registered = []
|
||||
const ctx = {
|
||||
skills: {
|
||||
register(skill) {
|
||||
registered.push(skill)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, info() {} },
|
||||
}
|
||||
return { ctx, registered }
|
||||
}
|
||||
|
||||
function withTempHome(t) {
|
||||
const previous = process.env.DSH_HOME
|
||||
const home = mkdtempSync(join(tmpdir(), 'netxops-skills-'))
|
||||
process.env.DSH_HOME = home
|
||||
t.after(() => {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
resetMcpCatalog()
|
||||
})
|
||||
return home
|
||||
}
|
||||
|
||||
const FULL_RESOURCES = skillResources([
|
||||
{
|
||||
group: 'ops',
|
||||
groupDir: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
description: 'Ops playbook for tests',
|
||||
},
|
||||
{
|
||||
group: 'ops',
|
||||
groupDir: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'reference.md',
|
||||
text: OPS_REF,
|
||||
},
|
||||
{
|
||||
group: 'topology',
|
||||
groupDir: 'topology',
|
||||
skill: 'netx-topology',
|
||||
relPath: 'SKILL.md',
|
||||
text: TOPO_SKILL,
|
||||
description: 'Topology playbook',
|
||||
},
|
||||
])
|
||||
|
||||
test('refresh + register: normal skill with reference.md', async (t) => {
|
||||
withTempHome(t)
|
||||
const mock = await startMockMcp({ resources: FULL_RESOURCES })
|
||||
t.after(() => mock.server.close())
|
||||
publishNetxConnection({
|
||||
apiUrl: mock.apiUrl,
|
||||
token: 'secret',
|
||||
lang: 'zh',
|
||||
toolCallTimeoutMs: 5_000,
|
||||
toolsEnabled: true,
|
||||
})
|
||||
const catalog = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(catalog.phase, 'connected')
|
||||
assert.equal(catalog.tools.length, 1)
|
||||
const cache = skillsCacheDir(mock.apiUrl)
|
||||
assert.ok(existsSync(join(cache, 'ops', 'netx-ops', 'SKILL.md')))
|
||||
assert.ok(existsSync(join(cache, 'ops', 'netx-ops', 'reference.md')))
|
||||
assert.equal(readFileSync(join(cache, 'ops', 'netx-ops', 'reference.md'), 'utf8'), OPS_REF)
|
||||
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
const dispose = await registerGroupSkills(ctx, ['ops'], 'test')
|
||||
assert.equal(registered.length, 1)
|
||||
assert.equal(registered[0].name, 'netx-ops')
|
||||
assert.match(registered[0].content, /queryNmsAlarms/)
|
||||
assert.equal(registered[0].resourceBase?.path, join(cache, 'ops', 'netx-ops'))
|
||||
assert.ok(registered[0].path?.endsWith('SKILL.md'))
|
||||
dispose()
|
||||
})
|
||||
|
||||
test('each group dir has a playbook with name + description', () => {
|
||||
const root = opsSkillsRoot()
|
||||
for (const group of Object.keys(SKILL_DIR_BY_GROUP)) {
|
||||
const groupDir = join(root, SKILL_DIR_BY_GROUP[group])
|
||||
const bundles = readdirSync(groupDir).filter((name) => existsSync(join(groupDir, name, 'SKILL.md')))
|
||||
assert.ok(bundles.length >= 1, group)
|
||||
const raw = readFileSync(join(groupDir, bundles[0], 'SKILL.md'), 'utf8')
|
||||
assert.match(raw, /^---\r?\n/)
|
||||
assert.match(raw, /\nname:\s+\S+/)
|
||||
assert.match(raw, /\ndescription:\s/)
|
||||
}
|
||||
test('server without resources capability → no skills, tools still ok', async (t) => {
|
||||
withTempHome(t)
|
||||
// Pre-seed stale cache that must be cleared on successful tools refresh.
|
||||
replaceSkillsCache(API, [{
|
||||
group: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
}])
|
||||
assert.equal(hasSkillsCache(API), true)
|
||||
|
||||
const mock = await startMockMcp({ resources: null })
|
||||
t.after(() => mock.server.close())
|
||||
const catalog = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(catalog.phase, 'connected')
|
||||
assert.equal(catalog.tools.length, 1)
|
||||
assert.equal(hasSkillsCache(mock.apiUrl), false)
|
||||
|
||||
publishNetxConnection({
|
||||
apiUrl: mock.apiUrl, token: 'secret', lang: 'zh', toolCallTimeoutMs: 5_000,
|
||||
})
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
await registerGroupSkills(ctx, ['ops'], 'test')
|
||||
assert.equal(registered.length, 0)
|
||||
})
|
||||
|
||||
test('resources call failure keeps prior skill cache, tools still ok', async (t) => {
|
||||
withTempHome(t)
|
||||
const mock = await startMockMcp({ failResources: true })
|
||||
t.after(() => mock.server.close())
|
||||
replaceSkillsCache(mock.apiUrl, [{
|
||||
group: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
}])
|
||||
const catalog = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(catalog.phase, 'connected')
|
||||
assert.equal(catalog.tools.length, 1)
|
||||
assert.equal(hasSkillsCache(mock.apiUrl), true)
|
||||
})
|
||||
|
||||
test('offline refresh keeps prior skill cache', async (t) => {
|
||||
withTempHome(t)
|
||||
const mock = await startMockMcp({ resources: FULL_RESOURCES })
|
||||
const ok = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(ok.phase, 'connected')
|
||||
mock.server.close()
|
||||
|
||||
const offline = await startMockMcp({ failResources: true, tools: [] })
|
||||
// Fail tools/list entirely
|
||||
offline.server.close()
|
||||
const down = http.createServer((_req, res) => {
|
||||
res.writeHead(503)
|
||||
res.end('down')
|
||||
})
|
||||
await new Promise((resolve) => down.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = down.address()
|
||||
t.after(() => down.close())
|
||||
// Same apiUrl as successful pull so cache key matches — use mock.apiUrl host
|
||||
// but port changed; instead keep using the first mock's cache by refreshing
|
||||
// the same apiUrl against a dead server.
|
||||
const deadUrl = mock.apiUrl
|
||||
const next = await refreshMcpCatalog({ apiUrl: deadUrl, token: 'secret', timeoutMs: 500 })
|
||||
assert.ok(next.phase === 'offline' || next.phase === 'error')
|
||||
assert.equal(hasSkillsCache(deadUrl), true)
|
||||
publishNetxConnection({
|
||||
apiUrl: deadUrl, token: 'secret', lang: 'zh', toolCallTimeoutMs: 5_000,
|
||||
})
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
await registerGroupSkills(ctx, ['ops'], 'test')
|
||||
assert.equal(registered[0]?.name, 'netx-ops')
|
||||
})
|
||||
|
||||
test('refresh clears deleted skills from cache', async (t) => {
|
||||
withTempHome(t)
|
||||
const slim = skillResources([
|
||||
{
|
||||
group: 'ops',
|
||||
groupDir: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
description: 'Ops playbook for tests',
|
||||
},
|
||||
])
|
||||
const mock = await startMockMcp({ resources: slim })
|
||||
t.after(() => mock.server.close())
|
||||
replaceSkillsCache(mock.apiUrl, [
|
||||
{ group: 'ops', skill: 'netx-ops', relPath: 'SKILL.md', text: OPS_SKILL },
|
||||
{ group: 'topology', skill: 'netx-topology', relPath: 'SKILL.md', text: TOPO_SKILL },
|
||||
])
|
||||
assert.ok(existsSync(join(skillsCacheDir(mock.apiUrl), 'topology', 'netx-topology', 'SKILL.md')))
|
||||
await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.ok(existsSync(join(skillsCacheDir(mock.apiUrl), 'ops', 'netx-ops', 'SKILL.md')))
|
||||
assert.equal(existsSync(join(skillsCacheDir(mock.apiUrl), 'topology', 'netx-topology', 'SKILL.md')), false)
|
||||
})
|
||||
|
||||
test('group off → skill not registered', async (t) => {
|
||||
withTempHome(t)
|
||||
replaceSkillsCache(API, [
|
||||
{ group: 'ops', skill: 'netx-ops', relPath: 'SKILL.md', text: OPS_SKILL },
|
||||
{ group: 'topology', skill: 'netx-topology', relPath: 'SKILL.md', text: TOPO_SKILL },
|
||||
])
|
||||
publishNetxConnection({
|
||||
apiUrl: API, token: 'secret', lang: 'zh', toolCallTimeoutMs: 5_000,
|
||||
})
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
await registerGroupSkills(ctx, ['topology'], 'test')
|
||||
assert.equal(registered.length, 1)
|
||||
assert.equal(registered[0].name, 'netx-topology')
|
||||
registered.length = 0
|
||||
await registerGroupSkills(ctx, [], 'test')
|
||||
assert.equal(registered.length, 0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import http from 'node:http'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
filterCatalogTools,
|
||||
|
|
@ -16,6 +19,17 @@ import { listMcpTools, callMcpTool, mcpEndpointUrl } from '../src/netx/mcp-remot
|
|||
import { refreshMcpCatalog, getMcpCatalogState, resetMcpCatalog } from '../src/netx/mcp-store.ts'
|
||||
import { DEFAULT_CAPABILITY_GROUPS } from '../src/netx/capability-groups.ts'
|
||||
|
||||
function withTempDshHome(t) {
|
||||
const previous = process.env.DSH_HOME
|
||||
const home = mkdtempSync(join(tmpdir(), 'netxops-mcp-catalog-'))
|
||||
process.env.DSH_HOME = home
|
||||
t.after(() => {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
})
|
||||
}
|
||||
|
||||
const SAMPLE = Object.freeze([
|
||||
{
|
||||
name: 'queryNmsAlarms',
|
||||
|
|
@ -196,7 +210,29 @@ test('listMcpTools + callMcpTool against mock Streamable HTTP server', async ()
|
|||
}
|
||||
})
|
||||
|
||||
test('refreshMcpCatalog keeps cache on disconnect', async () => {
|
||||
test('listMcpTools drops group=meta tools (getNetxSkill)', async () => {
|
||||
const withMeta = [
|
||||
...SAMPLE,
|
||||
{
|
||||
name: 'getNetxSkill',
|
||||
description: 'read skill',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: true,
|
||||
group: 'meta',
|
||||
},
|
||||
]
|
||||
const mock = await startMockMcp(withMeta)
|
||||
try {
|
||||
const tools = await listMcpTools({ mcpUrl: mock.url, token: 'secret' })
|
||||
assert.equal(tools.length, SAMPLE.length)
|
||||
assert.equal(tools.some((t) => t.name === 'getNetxSkill'), false)
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('refreshMcpCatalog keeps cache on disconnect', async (t) => {
|
||||
withTempDshHome(t)
|
||||
resetMcpCatalog()
|
||||
const mock = await startMockMcp(SAMPLE)
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import { replaceSkillsCache } from '../src/netx/skills-cache.ts'
|
||||
|
||||
const STORE_KEY = Symbol.for('dsh-netxops.mcp-catalog-store')
|
||||
|
||||
|
|
@ -86,13 +87,41 @@ export function fullCatalog() {
|
|||
]
|
||||
}
|
||||
|
||||
const DEFAULT_API = 'http://127.0.0.1:8890'
|
||||
|
||||
const MINIMAL_SKILLS = Object.freeze([
|
||||
{
|
||||
group: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: '---\nname: netx-ops\ndescription: Ops playbook\n---\nOps body\n',
|
||||
},
|
||||
{
|
||||
group: 'topology',
|
||||
skill: 'netx-topology',
|
||||
relPath: 'SKILL.md',
|
||||
text: '---\nname: netx-topology\ndescription: Topology playbook\n---\nTopology body\n',
|
||||
},
|
||||
{
|
||||
group: 'bizMonitor',
|
||||
skill: 'netx-biz-monitor',
|
||||
relPath: 'SKILL.md',
|
||||
text: '---\nname: netx-biz-monitor\ndescription: Biz monitor playbook\n---\nBiz body\n',
|
||||
},
|
||||
])
|
||||
|
||||
/** Write a minimal MCP skills cache for `apiUrl` (default test netx). */
|
||||
export function seedSkillsCache(apiUrl = DEFAULT_API) {
|
||||
return replaceSkillsCache(apiUrl, MINIMAL_SKILLS)
|
||||
}
|
||||
|
||||
/** Install a frozen catalog into the shared Symbol.for store (no network). */
|
||||
export function seedMcpCatalog(tools = fullCatalog()) {
|
||||
const root = globalThis
|
||||
const store = {
|
||||
state: {
|
||||
phase: 'connected',
|
||||
mcpUrl: 'http://127.0.0.1:8890/mcp',
|
||||
mcpUrl: `${DEFAULT_API}/mcp`,
|
||||
tools: Object.freeze([...tools]),
|
||||
error: '',
|
||||
listedAt: new Date().toISOString(),
|
||||
|
|
@ -102,6 +131,7 @@ export function seedMcpCatalog(tools = fullCatalog()) {
|
|||
generation: 1,
|
||||
}
|
||||
root[STORE_KEY] = store
|
||||
seedSkillsCache(DEFAULT_API)
|
||||
return store.state
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ test('the npm tarball installs offline and supplies every exported artifact and
|
|||
for (const path of Object.values(pkg.exports).filter(path => !path.includes('*'))) assert.ok(names.has(path.slice(2)), `Missing packed export ${path}`)
|
||||
for (const path of ['icon.svg', 'locale/en.json', 'locale/zh.json', pkg.dsh.bundle.patch.slice(2),
|
||||
'presets/netxops/PERSONA.md', 'presets/netxops/preset.yml',
|
||||
'presets/netxops/skills/ops/netx-ops/SKILL.md',
|
||||
'presets/netxops/skills/topology/netx-topology/SKILL.md',
|
||||
'presets/netxops/skills/biz-monitor/netx-biz-monitor/SKILL.md',
|
||||
]) assert.ok(names.has(path), `Missing packed resource ${path}`)
|
||||
for (const path of names) assert.doesNotMatch(path, /^(?:src|test|tests|examples|scripts|node_modules)\/|\.env|\.npmrc/, `Private/developer file included: ${path}`)
|
||||
for (const path of names) {
|
||||
assert.doesNotMatch(path, /^presets\/netxops\/skills\//, `Bundled group skill must not ship: ${path}`)
|
||||
assert.doesNotMatch(path, /^(?:src|test|tests|examples|scripts|node_modules)\/|\.env|\.npmrc/, `Private/developer file included: ${path}`)
|
||||
}
|
||||
const profile = join(directory, '全新安装 profile with spaces')
|
||||
mkdirSync(profile)
|
||||
writeFileSync(join(profile, 'package.json'), JSON.stringify({ name: 'isolated-netxops-profile', private: true }))
|
||||
|
|
@ -78,42 +78,46 @@ test('the npm tarball installs offline and supplies every exported artifact and
|
|||
} catch { /* Try a complete Host generation, never mix peers. */ }
|
||||
}
|
||||
if (!peers) { console.log('No installed Harness runtime: offline installation verified; Host activation requires a local Harness.'); return }
|
||||
// A neighboring package must never override the skills shipped to users.
|
||||
const trap = join(profile, 'node_modules', 'netx', 'skills', 'ops', 'netx-ops')
|
||||
mkdirSync(trap, { recursive: true })
|
||||
writeFileSync(join(trap, 'SKILL.md'), '---\nname: unexpected-neighbor\ndescription: Must not load\n---\nwrong package')
|
||||
const loader = join(directory, 'host-loader.mjs')
|
||||
writeFileSync(loader, `let peers; export function initialize(data) { peers = data }
|
||||
export async function resolve(name, ctx, next) { return peers[name] ? { url: peers[name], shortCircuit: true } : next(name, ctx) }`)
|
||||
const smoke = join(directory, 'activate-installed.mjs')
|
||||
writeFileSync(smoke, `import assert from 'node:assert/strict'; import { register } from 'node:module';
|
||||
writeFileSync(smoke, `import assert from 'node:assert/strict'; import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path';
|
||||
import { register } from 'node:module';
|
||||
register(${JSON.stringify(pathToFileURL(loader).href)}, { data: ${JSON.stringify(peers)} });
|
||||
const { Context, Service } = await import('@deepseek-ai/cordis');
|
||||
const host = await import(${JSON.stringify(pathToFileURL(join(installed, 'lib/index.js')).href)});
|
||||
const root = new Context(), tools = new Map(), skills = new Map(), owners = [];
|
||||
class Tools extends Service { constructor(ctx) { super(ctx, 'tools') } register(entry) { assert.ok(!tools.has(entry.name)); tools.set(entry.name, entry); return () => tools.delete(entry.name) } }
|
||||
class Skills extends Service { constructor(ctx) { super(ctx, 'skills') } register(entry) { assert.ok(!skills.has(entry.name)); skills.set(entry.name, entry); return () => skills.delete(entry.name) } }
|
||||
// Seed a cached /mcp catalog so host registration does not need a live netx.
|
||||
// Seed a cached /mcp catalog + on-disk skills cache (no live netx).
|
||||
const STORE = Symbol.for('dsh-netxops.mcp-catalog-store');
|
||||
const ops = ${JSON.stringify([...OPS_TOOL_NAMES_INLINE])};
|
||||
const apiUrl = 'http://127.0.0.1:8890';
|
||||
globalThis[STORE] = { generation: 1, listeners: new Set(), state: {
|
||||
phase: 'connected', mcpUrl: 'http://127.0.0.1:8890/mcp', error: '', listedAt: new Date().toISOString(), fromCache: false,
|
||||
phase: 'connected', mcpUrl: apiUrl + '/mcp', error: '', listedAt: new Date().toISOString(), fromCache: false,
|
||||
tools: ops.map(name => ({ name, description: name, inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: name !== 'execManagedNe', group: 'ops' })),
|
||||
} };
|
||||
const dshHome = process.env.DSH_HOME;
|
||||
const key = createHash('sha256').update(apiUrl).digest('hex').slice(0, 16);
|
||||
const skillDir = join(dshHome, 'netxops-mcp-skills', key, 'ops', 'netx-ops');
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '---\\nname: netx-ops\\ndescription: Ops playbook\\n---\\nOps body\\n');
|
||||
owners.push(await root.plugin(Tools), await root.plugin(Skills));
|
||||
const owner = await root.plugin(host, { installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true }); owners.push(owner);
|
||||
assert.equal(owner.state, 2); assert.equal(tools.size, 15);
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
assert.ok(skills.has('netx-ops')); assert.equal(skills.has('unexpected-neighbor'), false);
|
||||
assert.ok(skills.get('netx-ops').path.startsWith(${JSON.stringify(installed)}));
|
||||
assert.ok(skills.has('netx-ops'));
|
||||
assert.ok(skills.get('netx-ops').path.startsWith(dshHome));
|
||||
for (const path of ${JSON.stringify(Object.values(pkg.exports).filter(path => path.endsWith('.js') && path !== './lib/client.js'))}) {
|
||||
const api = await import(new URL(path, ${JSON.stringify(pathToFileURL(join(installed, 'package.json')).href)})); assert.equal(typeof api.apply, 'function');
|
||||
}
|
||||
for (const owner of owners.reverse()) await owner.dispose();
|
||||
await new Promise(resolve => setTimeout(resolve, 40)); assert.equal(tools.size, 0); assert.equal(skills.size, 0);
|
||||
console.log('Installed Host active: 15 tools, bundled ops skill, clean unload.');`)
|
||||
run([smoke], profile, { DSH_HOME: join(directory, 'isolated-dsh-home'), NETX_SKILLS_ROOT: '' })
|
||||
console.log('Installed Host active: 15 tools, cached ops skill, clean unload.');`)
|
||||
run([smoke], profile, { DSH_HOME: join(directory, 'isolated-dsh-home') })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue