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:
oliver 2026-10-16 23:14:39 +08:00
parent 277a61894d
commit fa82f530e9
31 changed files with 2074 additions and 2027 deletions

View file

@ -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',
})

View file

@ -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)
})

View file

@ -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 {

View file

@ -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
}

View file

@ -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') })
}
})