mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-19 21:44:06 +08:00
- Strip UTF-8 BOM before parsing MANIFEST.json (PowerShell 5.1 output).
- kb.reload force-republishes so edited SKILL.md / new local_skills / identity apply.
- Duplicate or rejected pack skills are skipped instead of unregistering the whole pack.
- kb-context and every pack skill carry resolved absolute paths.* anchors; KB_PATHS env.
- Inline {paths.local}/identity/*.md into kb-context (capped).
- New netx__kbSearch / netx__kbRead host tools, realpath-jailed to kbRoot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
7.3 KiB
JavaScript
174 lines
7.3 KiB
JavaScript
/**
|
|
* KB loading: BOM MANIFEST, resolved path anchors, identity inlining, pack footer /
|
|
* duplicate isolation, forced reload, and jailed read/search tools.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { basename, join } from 'node:path'
|
|
import test from 'node:test'
|
|
|
|
import { registerKbContextSkill } from '../src/netx/kb-context-skill.ts'
|
|
import { resolveKbPaths, resolveKbRoot } from '../src/netx/kb-manifest.ts'
|
|
import { registerKbLocalTools } from '../src/netx/kb-local-tools.ts'
|
|
import { registerKbPackSkills } from '../src/netx/kb-pack-skills.ts'
|
|
import { readKbFile, searchKb } from '../src/netx/kb-read-ops.ts'
|
|
import { applyKbEnv, publishKbContext, resetKbContext, watchKbContext } from '../src/netx/kb-runtime.ts'
|
|
|
|
const LOCAL = 'regions/ID/IOH/_local'
|
|
const BOM = String.fromCharCode(0xfeff)
|
|
|
|
function manifest() {
|
|
return JSON.stringify({
|
|
schemaVersion: '1.0',
|
|
packageType: 'operator-subset',
|
|
operator: { name: 'IOH', country: 'ID' },
|
|
version: '2026.09-v31',
|
|
content: { hasRegions: true, hasSkills: true },
|
|
paths: {
|
|
rca: 'regions/ID/IOH/00_有效RCA',
|
|
local: LOCAL,
|
|
localSkills: `${LOCAL}/local_skills`,
|
|
neIndex: 'regions/ID/IOH/00_索引/网元索引.md',
|
|
skills: '_skills',
|
|
},
|
|
}, null, 2)
|
|
}
|
|
|
|
function skill(dir, name) {
|
|
mkdirSync(dir, { recursive: true })
|
|
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name} playbook\n---\n\n# ${name}\n`, 'utf8')
|
|
}
|
|
|
|
/** Minimal operator subset on disk; MANIFEST written with a UTF-8 BOM (PowerShell 5.1). */
|
|
async function withKb(run) {
|
|
const root = mkdtempSync(join(tmpdir(), 'netxops-kb-load-'))
|
|
const outside = `${root}-secret.md`
|
|
try {
|
|
writeFileSync(join(root, 'MANIFEST.json'), `${BOM}${manifest()}`, 'utf8')
|
|
const rca = join(root, 'regions/ID/IOH/00_有效RCA/02_路由')
|
|
mkdirSync(rca, { recursive: true })
|
|
writeFileSync(join(rca, '20250911-MC-Aceh-Flap.md'), '# Aceh flap\n\n- 涉及网元 | MDN-BCP-CN1\n- BGP session flap\n', 'utf8')
|
|
writeFileSync(join(rca, '20250101-other.md'), '# other\n\nISIS only\n', 'utf8')
|
|
mkdirSync(join(root, 'regions/ID/IOH/00_索引'), { recursive: true })
|
|
writeFileSync(join(root, 'regions/ID/IOH/00_索引/网元索引.md'), 'line1\nline2\nline3\n', 'utf8')
|
|
mkdirSync(join(root, LOCAL, 'identity'), { recursive: true })
|
|
writeFileSync(join(root, LOCAL, 'identity/README.md'), 'template only', 'utf8')
|
|
writeFileSync(join(root, LOCAL, 'identity/话术与边界.md'), '不对外承诺恢复时间', 'utf8')
|
|
skill(join(root, '_skills/kb-troubleshoot'), 'kb-troubleshoot')
|
|
skill(join(root, '_skills/kb-retrieve'), 'kb-retrieve')
|
|
skill(join(root, LOCAL, 'local_skills/kb-retrieve'), 'kb-retrieve')
|
|
skill(join(root, LOCAL, 'local_skills/ioh-private'), 'ioh-private')
|
|
writeFileSync(outside, 'outside', 'utf8')
|
|
await run(root)
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true })
|
|
rmSync(outside, { force: true })
|
|
}
|
|
}
|
|
|
|
function fakeCtx() {
|
|
const skills = new Map()
|
|
const tools = new Map()
|
|
const warnings = []
|
|
// Mirrors DSH: registering an existing name throws.
|
|
const register = map => (entry) => {
|
|
if (map.has(entry.name)) throw new Error(`duplicate: ${entry.name}`)
|
|
map.set(entry.name, entry)
|
|
return () => { map.delete(entry.name) }
|
|
}
|
|
return {
|
|
skills,
|
|
tools,
|
|
warnings,
|
|
ctx: {
|
|
logger: { info() {}, warn(...args) { warnings.push(args) }, error() {} },
|
|
skills: { register: register(skills) },
|
|
tools: { register: register(tools) },
|
|
},
|
|
}
|
|
}
|
|
|
|
test('MANIFEST with UTF-8 BOM resolves as configured', () => withKb((root) => {
|
|
const kb = resolveKbRoot(root)
|
|
assert.equal(kb.status, 'configured', kb.errorMessage)
|
|
assert.equal(kb.version, '2026.09-v31')
|
|
}))
|
|
|
|
test('resolveKbPaths returns absolute anchors and KB_PATHS mirrors them', () => withKb((root) => {
|
|
const kb = resolveKbRoot(root)
|
|
const rows = Object.fromEntries(resolveKbPaths(kb).map(row => [row.key, row]))
|
|
assert.equal(rows.rca.exists, true)
|
|
assert.equal(rows.neIndex.absolute, join(root, 'regions/ID/IOH/00_索引/网元索引.md'))
|
|
applyKbEnv(kb)
|
|
assert.equal(JSON.parse(process.env.KB_PATHS).rca, rows.rca.absolute)
|
|
resetKbContext()
|
|
assert.equal(process.env.KB_PATHS, undefined)
|
|
}))
|
|
|
|
test('kb-context carries path anchors and inlines identity files (not README)', () => withKb((root) => {
|
|
const { ctx, skills } = fakeCtx()
|
|
registerKbContextSkill(ctx, resolveKbRoot(root))
|
|
const body = skills.get('kb-context').content
|
|
assert.ok(body.includes(join(root, 'regions/ID/IOH/00_索引/网元索引.md')))
|
|
assert.ok(body.includes('不对外承诺恢复时间'))
|
|
assert.equal(body.includes('template only'), false)
|
|
assert.match(skills.get('kb-context').description, /IOH/)
|
|
}))
|
|
|
|
test('pack skills get the anchor footer; duplicate local name is skipped, not fatal', () => withKb(async (root) => {
|
|
const { ctx, skills, warnings } = fakeCtx()
|
|
const dispose = await registerKbPackSkills(ctx, resolveKbRoot(root), { enabled: true })
|
|
assert.deepEqual([...skills.keys()].sort(), ['ioh-private', 'kb-retrieve', 'kb-troubleshoot'])
|
|
assert.ok(skills.get('kb-troubleshoot').content.includes(join(root, 'regions/ID/IOH/00_有效RCA')))
|
|
assert.ok(warnings.some(args => String(args[0]).includes('duplicates')))
|
|
dispose()
|
|
assert.equal(skills.size, 0)
|
|
}))
|
|
|
|
test('publishKbContext force re-notifies an unchanged snapshot', () => withKb((root) => {
|
|
const kb = resolveKbRoot(root)
|
|
publishKbContext(kb)
|
|
let calls = 0
|
|
const stop = watchKbContext(() => { calls += 1 })
|
|
publishKbContext(kb)
|
|
assert.equal(calls, 0)
|
|
publishKbContext(kb, { force: true })
|
|
assert.equal(calls, 1)
|
|
stop()
|
|
resetKbContext()
|
|
}))
|
|
|
|
test('kbSearch ranks multi-term hits first and honours scope', () => withKb((root) => {
|
|
const kb = resolveKbRoot(root)
|
|
const result = searchKb(kb, { query: 'mdn-bcp-cn bgp', scope: 'rca' })
|
|
assert.ok(result.hits[0].relativePath.endsWith('20250911-MC-Aceh-Flap.md'))
|
|
assert.deepEqual(result.hits[0].matchedTerms.sort(), ['bgp', 'mdn-bcp-cn'])
|
|
assert.equal(result.hits[0].lines[0].line, 3)
|
|
assert.equal(searchKb(kb, { query: 'isis', scope: 'neIndex' }).totalMatches, 0)
|
|
assert.equal(searchKb(kb, { query: 'IS.S\\s+only', regex: true }).totalMatches, 1)
|
|
assert.throws(() => searchKb(kb, { query: '(', regex: true }), /invalid regex/)
|
|
}))
|
|
|
|
test('kbRead pages files, lists dirs, and stays inside kbRoot', () => withKb((root) => {
|
|
const kb = resolveKbRoot(root)
|
|
const page = readKbFile(kb, { path: 'neIndex', offset: 2, limit: 1 })
|
|
assert.equal(page.text, 'line2')
|
|
assert.equal(page.truncated, true)
|
|
const dir = readKbFile(kb, { path: 'rca' })
|
|
assert.equal(dir.kind, 'directory')
|
|
assert.deepEqual(dir.entries.map(e => e.name), ['02_路由'])
|
|
assert.throws(() => readKbFile(kb, { path: `../${basename(root)}-secret.md` }), /escapes kbRoot/)
|
|
assert.throws(() => readKbFile(kb, { path: `${root}-secret.md` }), /escapes kbRoot/)
|
|
}))
|
|
|
|
test('read tools mount without paths.local; write tools need it', () => withKb((root) => {
|
|
const kb = resolveKbRoot(root)
|
|
const readOnly = fakeCtx()
|
|
registerKbLocalTools(readOnly.ctx, { ...kb, paths: { rca: kb.paths.rca } })
|
|
assert.deepEqual([...readOnly.tools.keys()].sort(), ['netx__kbRead', 'netx__kbSearch'])
|
|
const full = fakeCtx()
|
|
registerKbLocalTools(full.ctx, kb)
|
|
assert.equal(full.tools.size, 9)
|
|
}))
|