mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-19 20:23:19 +08:00
Fix KB loading bugs and add jailed KB read/search tools (0.1.71).
- 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>
This commit is contained in:
parent
9b2166e5b6
commit
2def53e7c7
22 changed files with 4286 additions and 1013 deletions
174
test/kb-loading.test.mjs
Normal file
174
test/kb-loading.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* 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)
|
||||
}))
|
||||
|
|
@ -147,10 +147,11 @@ test('knowledge-base tools contribute to the mount inventory and disappear on di
|
|||
const mount = fakeContext()
|
||||
const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } }
|
||||
const dispose = registerKbLocalTools(mount.ctx, snapshot, { plane: 'preset', source: 'kb-test' })
|
||||
assert.equal(mount.tools.size, 7)
|
||||
// 2 read (kbSearch / kbRead) + 7 local write/list tools.
|
||||
assert.equal(mount.tools.size, 9)
|
||||
const statuses = getToolMountStatuses()
|
||||
assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 7 && row.groups.includes('kb')))
|
||||
assert.equal(asToolsMountView({ mounts: statuses }).count, 7)
|
||||
assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 9 && row.groups.includes('kb')))
|
||||
assert.equal(asToolsMountView({ mounts: statuses }).count, 9)
|
||||
dispose()
|
||||
assert.equal(mount.tools.size, 0)
|
||||
assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue