| 1 | import test from 'node:test' |
| 2 | import assert from 'node:assert/strict' |
| 3 | import { SkillRoots, normalizeSkillRoot } from '../dist/skills.mjs' |
| 4 | |
| 5 | function fixture({ deferred = false, refused } = {}) { |
| 6 | const calls = [], pending = [] |
| 7 | let next = 0 |
| 8 | const rpc = { request(method, params) { |
| 9 | calls.push({ method, params }) |
| 10 | if (method === 'registry/unregister') return Promise.resolve({}) |
| 11 | const result = refused ? { refused } : { handle: ++next } |
| 12 | if (!deferred) return Promise.resolve(result) |
| 13 | return new Promise(resolve => pending.push(() => resolve(result))) |
| 14 | } } |
| 15 | const roots = new SkillRoots(rpc, owner => owner.roots, () => {}) |
| 16 | const owner = (id) => ({ ref: { plugin_id: id, generation: 1, owner_token: id }, state: 'active', pendingRegistrations: new Set(), refusals: [], roots: new Map() }) |
| 17 | return { roots, calls, pending, owner } |
| 18 | } |
| 19 | const settled = async owner => Promise.all([...owner.pendingRegistrations]) |
| 20 | |
| 21 | test('skill roots snapshot only bounded clean bundle-relative paths', () => { |
| 22 | const input = { path: 'profiles/review-skills' } |
| 23 | const normalized = normalizeSkillRoot(input) |
| 24 | input.path = '../outside' |
| 25 | assert.deepEqual(normalized, { path: 'profiles/review-skills' }) |
| 26 | assert.ok(Object.isFrozen(normalized)) |
| 27 | for (const path of ['', '/absolute', '../outside', './same', 'a/../b', 'a//b', 'a/', 'C:/disk', 'a\\b', 'a\u001bb', '界'.repeat(171)]) { |
| 28 | assert.throws(() => normalizeSkillRoot({ path }), /bounded bundle-relative/) |
| 29 | } |
| 30 | for (const value of [null, [], 'path', { path: 'good', priority: 1 }]) assert.throws(() => normalizeSkillRoot(value)) |
| 31 | }) |
| 32 | |
| 33 | test('owned skill roots dispose exact handles without crossing equal-path owners', async () => { |
| 34 | const { roots, calls, owner } = fixture() |
| 35 | const a = owner('a'), b = owner('b') |
| 36 | const undo = roots.register(a, { path: 'profiles/skills' }) |
| 37 | roots.register(b, { path: 'profiles/skills' }) |
| 38 | await Promise.all([settled(a), settled(b)]) |
| 39 | assert.deepEqual(calls.filter(call => call.method === 'registry/register').map(call => call.params.spec), [ |
| 40 | { name: 'profiles/skills', description: '' }, { name: 'profiles/skills', description: '' }, |
| 41 | ]) |
| 42 | undo(); undo() |
| 43 | await Promise.resolve() |
| 44 | assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1]) |
| 45 | assert.equal(b.roots.size, 1) |
| 46 | roots.forget(b) |
| 47 | await Promise.resolve() |
| 48 | assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1, 2]) |
| 49 | }) |
| 50 | |
| 51 | test('pending roots count toward limits and disposal before admission cannot remove replacement', async () => { |
| 52 | const { roots, calls, pending, owner } = fixture({ deferred: true }) |
| 53 | const a = owner('a') |
| 54 | const old = roots.register(a, { path: 'skills' }) |
| 55 | assert.throws(() => roots.register(a, { path: 'skills' }), /already registered/) |
| 56 | old() |
| 57 | roots.register(a, { path: 'skills' }) |
| 58 | for (let i = 0; i < 7; i++) roots.register(a, { path: `s${i}` }) |
| 59 | assert.throws(() => roots.register(a, { path: 'ninth' }), /limit/) |
| 60 | pending.forEach(resolve => resolve()) |
| 61 | await settled(a) |
| 62 | assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1)) |
| 63 | assert.ok(!calls.some(call => call.method === 'registry/unregister' && call.params.handle === 2)) |
| 64 | roots.forget(a) |
| 65 | roots.register(a, { path: 'after-disposal' }) |
| 66 | pending.at(-1)() |
| 67 | await settled(a) |
| 68 | assert.equal(a.roots.size, 1) |
| 69 | }) |
| 70 | |
| 71 | test('host root cap includes multiple owners and recovers after owner teardown', async () => { |
| 72 | const { roots, owner } = fixture() |
| 73 | const owners = Array.from({ length: 8 }, (_, i) => owner(String(i))) |
| 74 | for (const current of owners) for (let i = 0; i < 8; i++) roots.register(current, { path: `s${i}` }) |
| 75 | assert.throws(() => roots.register(owner('overflow'), { path: 's' }), /limit/) |
| 76 | await Promise.all(owners.map(settled)) |
| 77 | roots.forget(owners[0]) |
| 78 | const replacement = owner('replacement') |
| 79 | roots.register(replacement, { path: 's' }) |
| 80 | await settled(replacement) |
| 81 | assert.equal(replacement.roots.size, 1) |
| 82 | }) |
| 83 | |
| 84 | test('revoked owner cannot register and a late admitted root is withdrawn', async () => { |
| 85 | const { roots, calls, pending, owner } = fixture({ deferred: true }) |
| 86 | const a = owner('a') |
| 87 | roots.register(a, { path: 'skills' }) |
| 88 | a.state = 'disposed' |
| 89 | roots.forget(a) |
| 90 | assert.throws(() => roots.register(a, { path: 'late' }), /not live/) |
| 91 | pending.forEach(resolve => resolve()) |
| 92 | await settled(a) |
| 93 | assert.equal(a.roots.size, 0) |
| 94 | assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1)) |
| 95 | }) |
| 96 | |
| 97 | test('core refusal is preserved and local reservations retire with owner', async () => { |
| 98 | const { roots, owner } = fixture({ refused: 'reviewed bundle was changed' }) |
| 99 | const a = owner('a') |
| 100 | roots.register(a, { path: 'skills' }) |
| 101 | await settled(a) |
| 102 | assert.match(a.refusals[0], /skill_root.*reviewed bundle was changed/) |
| 103 | assert.equal(a.roots.size, 0) |
| 104 | roots.forget(a) |
| 105 | roots.register(a, { path: 'skills' }) |
| 106 | await settled(a) |
| 107 | }) |
| 108 |