feat(extensions): add owned reviewed skill root SDK

ctx.skills.registerRoot proposes bounded bundle-relative roots through the existing owned registration lifecycle; disposal withdraws the exact handle. The host only proposes Native-reviewed roots to Rust and never reads Skill bodies itself. Add a Native-only integration fixture and real-host admission/rollback cases.

Source validation: Node 22.20.0 focused source tests 6 passed, 0 failed; Bun 1.4.0 focused source tests 6 passed, 0 failed; strict TypeScript typecheck and diff whitespace check passed. Host dist and real-host tests await the coordinated parent build. Rust admission/catalog consumer slice is in progress and not yet compiled; no release or parity claim.
This commit is contained in:
CodeWhale Bot committed 2026-10-02 01:12:14 -07:00
1 parent c03bbf944a
commit 9bbd13814c
10 files changed
+281 -11

No files matched your search

@@ -159,7 +159,7 @@ export const SHAPES: { readonly [name: string]: Shape } = {
},
RegisterParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool', 'command', 'hook', 'prompt_section'] }, spec: { ref: 'RegisterSpecWire' } },
required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool', 'command', 'hook', 'prompt_section', 'skill_root'] }, spec: { ref: 'RegisterSpecWire' } },
optional: {},
},
RegisterSpecWire: {
@@ -312,7 +312,7 @@ export interface ProtocolRange {
max: number
}
export type RegisterKind = 'tool' | 'command' | 'hook' | 'prompt_section'
export type RegisterKind = 'tool' | 'command' | 'hook' | 'prompt_section' | 'skill_root'
export interface RegisterParams {
owner: OwnerRef
+2 -2
View File
@@ -200,8 +200,8 @@ export function validateMessage(
? 'a tool registration has no `spec.argument_hint`'
: kind === 'command' && spec.input_schema != null
? 'a command registration has no `spec.input_schema`'
: (kind === 'hook' || kind === 'prompt_section') && (spec.input_schema != null || spec.argument_hint != null)
? 'a hook or prompt registration has no input schema or argument hint'
: (kind === 'hook' || kind === 'prompt_section' || kind === 'skill_root') && (spec.input_schema != null || spec.argument_hint != null)
? 'a hook, prompt or skill root registration has no input schema or argument hint'
: undefined
if (reason !== undefined) throw new ProtocolError(`${method}: ${reason}`)
}
+13 -1
View File
@@ -32,6 +32,7 @@ import { OwnedRegistrations } from './shims/owned.ts'
import { hookExecution, hookVerdict, type LocalHook } from './shims/hooks.ts'
import { PromptSections, definePromptService, type LocalPromptSection } from './shims/prompt.ts'
import { createStorage, type PluginStorage } from './shims/storage.ts'
import { SkillRoots, defineSkillsService, type LocalSkillRoot } from './shims/skills.ts'
import { ownerTier, type HostTier } from './tier.ts'
import {
commandSpec,
@@ -70,7 +71,7 @@ export const REFUSED_SERVICES = new Set([
])
/** Services the root provides; `inject` of anything else fails activation. */
const PROVIDED_SERVICES = new Set(['tools', 'commands', 'prompt', 'storage', 'logger', 'events', 'reflect', 'registry'])
const PROVIDED_SERVICES = new Set(['tools', 'commands', 'prompt', 'storage', 'skills', 'logger', 'events', 'reflect', 'registry'])
const ACTIVATE_DEADLINE_MS = 5_000
const DISPOSE_DEADLINE_MS = 2_000
@@ -86,6 +87,7 @@ export interface OwnerRecord {
commands: Map<number, LocalCommand<OwnerRecord>>
hooks: Map<number, LocalHook<OwnerRecord>>
promptSections: Map<number, LocalPromptSection<OwnerRecord>>
skillRoots: Map<number, LocalSkillRoot<OwnerRecord>>
storage?: PluginStorage
warnedAllow?: boolean
/** Entry modules activated under this owner so far (a plugin may declare several). */
@@ -127,6 +129,7 @@ export class HostRoot {
private readonly commandRegistrations: OwnedRegistrations<OwnerRecord, LocalCommand<OwnerRecord>>
private readonly hookRegistrations: OwnedRegistrations<OwnerRecord, LocalHook<OwnerRecord>>
private readonly promptSections: PromptSections<OwnerRecord>
private readonly skillRoots: SkillRoots<OwnerRecord>
constructor(
private readonly rpc: RpcPeer,
@@ -152,6 +155,8 @@ export class HostRoot {
(message, owner) => this.log('warn', message, owner))
this.promptSections = new PromptSections(rpc, (owner) => owner.promptSections,
(message, owner) => this.log('warn', message, owner))
this.skillRoots = new SkillRoots(rpc, (owner) => owner.skillRoots,
(message, owner) => this.log('warn', message, owner))
// Cordis already owns listener effects and teardown. Intercept this one
// event using its supported extension point instead of replacing ctx.on.
@@ -224,6 +229,7 @@ export class HostRoot {
ownerOf: (ctx) => ctx[OWNER],
promptSections: this.promptSections,
})
const SkillsShim = defineSkillsService<OwnerRecord>({ ownerOf: (ctx) => ctx[OWNER], skillRoots: this.skillRoots })
class StorageShim extends Service {
constructor(ctx: any) { super(ctx, 'storage') }
private api(): PluginStorage {
@@ -244,10 +250,12 @@ export class HostRoot {
shimClasses.set('commands', CommandsShim)
shimClasses.set('prompt', PromptShim)
shimClasses.set('storage', StorageShim)
shimClasses.set('skills', SkillsShim)
root.plugin(ToolsShim)
root.plugin(CommandsShim)
root.plugin(PromptShim)
root.plugin(StorageShim)
root.plugin(SkillsShim)
}
log(level: string, msg: string, owner?: OwnerRecord) {
@@ -312,6 +320,7 @@ export class HostRoot {
commands: new Map(),
hooks: new Map(),
promptSections: new Map(),
skillRoots: new Map(),
entries: new Set(),
...(params.data_dir === undefined ? {} : { dataDir: params.data_dir }),
state: 'activating',
@@ -354,6 +363,7 @@ export class HostRoot {
owner.state = 'failed'
// All-or-nothing: dispose the partial fiber, rolling back every registration.
await this.disposeOwner(owner).catch(() => undefined)
this.skillRoots.forget(owner)
this.owners.delete(key)
return { status: 'failed', diagnostic: describeError(error) }
}
@@ -385,6 +395,7 @@ export class HostRoot {
...[...owner.commands.values()].map((command) => `command:${command.name}`),
...[...owner.hooks.values()].map((hook) => `hook:${hook.name}`),
...[...owner.promptSections.values()].map((section) => `prompt_section:${section.name}`),
...[...owner.skillRoots.values()].map((root) => `skill_root:${root.name}`),
]
for (const fiber of owner.fibers) {
for (const effect of fiber.getEffects?.() ?? []) leaked.push(`effect:${effect.label}`)
@@ -393,6 +404,7 @@ export class HostRoot {
this.commandRegistrations.forget(owner)
this.hookRegistrations.forget(owner)
this.promptSections.forget(owner)
this.skillRoots.forget(owner)
this.owners.delete(ref.owner_token)
return { disposed, leaked }
}
+16 -6
View File
@@ -40,13 +40,23 @@ export class OwnedRegistrations<O extends OwnerBase, T extends OwnedEntry<O>> {
/** Admitted entries by core handle. */
readonly byHandle = new Map<number, T>()
private readonly rpc: RpcPeer
private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'skill_root'
/** The owner's own index of this kind, for the leak report at deactivation. */
private readonly ownedBy: (owner: O) => Map<number, T>
private readonly warn: (message: string, owner: O) => void
constructor(
private readonly rpc: RpcPeer,
private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section',
/** The owner's own index of this kind, for the leak report at deactivation. */
private readonly ownedBy: (owner: O) => Map<number, T>,
private readonly warn: (message: string, owner: O) => void,
) {}
rpc: RpcPeer,
kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'skill_root',
ownedBy: (owner: O) => Map<number, T>,
warn: (message: string, owner: O) => void,
) {
this.rpc = rpc
this.kind = kind
this.ownedBy = ownedBy
this.warn = warn
}
/** Called inside the owner's effect; returns the effect's cleanup. */
add(entry: T, spec: RegisterSpec): () => void {
@@ -0,0 +1,68 @@
/** Reviewed bundle roots contribute to Rust's existing skill catalog. */
import { Service } from '@deepseek-ai/cordis'
import type { RpcPeer } from '../rpc.ts'
import { OwnedRegistrations, type OwnedEntry, type OwnerBase } from './owned.ts'
export const MAX_SKILL_ROOTS_PER_OWNER = 8
export const MAX_SKILL_ROOTS_PER_HOST = 64
export interface SkillRootDefinition { readonly path: string }
export interface LocalSkillRoot<O extends OwnerBase = OwnerBase> extends OwnedEntry<O> {}
export function normalizeSkillRoot(value: unknown): SkillRootDefinition {
if (typeof value !== 'object' || value === null || Array.isArray(value)
|| Object.keys(value).some(key => key !== 'path')) throw new TypeError('skill root supports only path')
const { path } = value as { path?: unknown }
if (typeof path !== 'string' || !path || Buffer.byteLength(path, 'utf8') > 512
|| /[\\:\u0000-\u001f\u007f-\u009f]/u.test(path)
|| path.split('/').some(part => !part || part === '.' || part === '..')) {
throw new TypeError('skill root path must be a bounded bundle-relative path with normal slash-separated components')
}
return Object.freeze({ path })
}
export class SkillRoots<O extends OwnerBase> {
private readonly registrations: OwnedRegistrations<O, LocalSkillRoot<O>>
private readonly owners = new Map<O, Map<string, () => void>>()
private count = 0
constructor(rpc: RpcPeer, ownedBy: (owner: O) => Map<number, LocalSkillRoot<O>>, warn: (message: string, owner: O) => void) {
this.registrations = new OwnedRegistrations(rpc, 'skill_root', ownedBy, warn)
}
register(owner: O, definition: SkillRootDefinition): () => void {
if (owner.state !== 'activating' && owner.state !== 'active') throw new Error('skill owner is not live')
const { path } = normalizeSkillRoot(definition)
const roots = this.owners.get(owner) ?? new Map<string, () => void>()
if (roots.has(path)) throw new Error('skill root is already registered; dispose it before registering it again')
if (roots.size >= MAX_SKILL_ROOTS_PER_OWNER || this.count >= MAX_SKILL_ROOTS_PER_HOST) throw new RangeError('skill root owner or host registration limit reached')
const undo = this.registrations.add({ owner, name: path, disposed: false }, { name: path, description: '' })
const dispose = () => {
if (roots.get(path) !== dispose) return
roots.delete(path)
this.count--
if (!roots.size) this.owners.delete(owner)
undo()
}
roots.set(path, dispose)
this.owners.set(owner, roots)
this.count++
return dispose
}
forget(owner: O) {
for (const dispose of [...(this.owners.get(owner)?.values() ?? [])]) dispose()
this.registrations.forget(owner)
}
}
export function defineSkillsService<O extends OwnerBase>(host: { ownerOf(ctx: any): O | undefined, skillRoots: SkillRoots<O> }) {
class SkillsShim extends Service {
constructor(ctx: any) { super(ctx, 'skills') }
registerRoot(definition: SkillRootDefinition): () => void {
const ctx: any = this.ctx
const owner = host.ownerOf(ctx)
if (!owner) throw new Error('skills.registerRoot called outside an extension owner')
const root = normalizeSkillRoot(definition)
return ctx.effect(() => host.skillRoots.register(owner, root), `skills.registerRoot(${JSON.stringify(root.path)})`)
}
}
Object.freeze(SkillsShim.prototype)
return SkillsShim
}
@@ -0,0 +1,56 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { activate, startHost } from './harness.mjs'
function plugin(t, body) {
const directory = mkdtempSync(join(tmpdir(), 'cw-skill-root-'))
const entry = join(directory, 'index.mjs')
writeFileSync(entry, `export const inject = ['skills']\nexport function apply(ctx) {\n${body}\n}\n`)
t.after(() => rmSync(directory, { recursive: true, force: true }))
return entry
}
test('real host admits detached skill root proposals and disposes only their owner', async (t) => {
const host = await startHost()
t.after(() => host.stop())
const entry = plugin(t, `const root = { path: 'profiles/review-skills' }; ctx.skills.registerRoot(root); root.path = '../changed'`)
const a = await activate(host, 'skill-a', entry), b = await activate(host, 'skill-b', entry)
assert.equal(a.result.status, 'ok'); assert.equal(b.result.status, 'ok')
const registrations = host.registry.filter(item => item.op === 'register' && item.kind === 'skill_root')
assert.equal(registrations.length, 2)
assert.deepEqual(registrations[0].spec, { name: 'profiles/review-skills', description: '' })
assert.deepEqual(await host.call('ext/deactivate', { owner: a.ref }), { disposed: true, leaked: [] })
assert.ok(host.registry.some(item => item.op === 'unregister' && item.handle === registrations[0].handle))
assert.ok(!host.registry.some(item => item.op === 'unregister' && item.handle === registrations[1].handle))
})
test('invalid and duplicate skill roots fail activation and roll back proposals', async (t) => {
const host = await startHost()
t.after(() => host.stop())
const cases = [
[`ctx.skills.registerRoot({ path: '../outside' })`, /bundle-relative/],
[`ctx.skills.registerRoot({ path: 'skills', watch: true })`, /only path/],
[`ctx.skills.registerRoot({ path: 'skills' }); ctx.skills.registerRoot({ path: 'skills' })`, /already registered/],
]
for (const [index, [body, reason]] of cases.entries()) {
const before = host.registry.length
const { result } = await activate(host, `bad-root-${index}`, plugin(t, body))
assert.equal(result.status, 'failed'); assert.match(result.diagnostic, reason)
for (const item of host.registry.slice(before).filter(item => item.op === 'register')) {
if (!host.registry.some(later => later.op === 'unregister' && later.handle === item.handle)) {
await host.waitFor(message => message.method === 'registry/unregister' && message.params.handle === item.handle)
}
}
}
})
test('real host preserves Rust root admission refusal in activation result', async (t) => {
const host = await startHost({ admit: () => ({ refused: 'root is outside the reviewed inventory' }) })
t.after(() => host.stop())
const { result } = await activate(host, 'refused-root', plugin(t, `ctx.skills.registerRoot({ path: 'skills' })`))
assert.equal(result.status, 'failed')
assert.match(result.diagnostic, /outside the reviewed inventory/)
})
@@ -0,0 +1,107 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { SkillRoots, normalizeSkillRoot } from '../src/shims/skills.ts'
function fixture({ deferred = false, refused } = {}) {
const calls = [], pending = []
let next = 0
const rpc = { request(method, params) {
calls.push({ method, params })
if (method === 'registry/unregister') return Promise.resolve({})
const result = refused ? { refused } : { handle: ++next }
if (!deferred) return Promise.resolve(result)
return new Promise(resolve => pending.push(() => resolve(result)))
} }
const roots = new SkillRoots(rpc, owner => owner.roots, () => {})
const owner = (id) => ({ ref: { plugin_id: id, generation: 1, owner_token: id }, state: 'active', pendingRegistrations: new Set(), refusals: [], roots: new Map() })
return { roots, calls, pending, owner }
}
const settled = async owner => Promise.all([...owner.pendingRegistrations])
test('skill roots snapshot only bounded clean bundle-relative paths', () => {
const input = { path: 'profiles/review-skills' }
const normalized = normalizeSkillRoot(input)
input.path = '../outside'
assert.deepEqual(normalized, { path: 'profiles/review-skills' })
assert.ok(Object.isFrozen(normalized))
for (const path of ['', '/absolute', '../outside', './same', 'a/../b', 'a//b', 'a/', 'C:/disk', 'a\\b', 'a\u001bb', '界'.repeat(171)]) {
assert.throws(() => normalizeSkillRoot({ path }), /bounded bundle-relative/)
}
for (const value of [null, [], 'path', { path: 'good', priority: 1 }]) assert.throws(() => normalizeSkillRoot(value))
})
test('owned skill roots dispose exact handles without crossing equal-path owners', async () => {
const { roots, calls, owner } = fixture()
const a = owner('a'), b = owner('b')
const undo = roots.register(a, { path: 'profiles/skills' })
roots.register(b, { path: 'profiles/skills' })
await Promise.all([settled(a), settled(b)])
assert.deepEqual(calls.filter(call => call.method === 'registry/register').map(call => call.params.spec), [
{ name: 'profiles/skills', description: '' }, { name: 'profiles/skills', description: '' },
])
undo(); undo()
await Promise.resolve()
assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1])
assert.equal(b.roots.size, 1)
roots.forget(b)
await Promise.resolve()
assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1, 2])
})
test('pending roots count toward limits and disposal before admission cannot remove replacement', async () => {
const { roots, calls, pending, owner } = fixture({ deferred: true })
const a = owner('a')
const old = roots.register(a, { path: 'skills' })
assert.throws(() => roots.register(a, { path: 'skills' }), /already registered/)
old()
roots.register(a, { path: 'skills' })
for (let i = 0; i < 7; i++) roots.register(a, { path: `s${i}` })
assert.throws(() => roots.register(a, { path: 'ninth' }), /limit/)
pending.forEach(resolve => resolve())
await settled(a)
assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1))
assert.ok(!calls.some(call => call.method === 'registry/unregister' && call.params.handle === 2))
roots.forget(a)
roots.register(a, { path: 'after-disposal' })
pending.at(-1)()
await settled(a)
assert.equal(a.roots.size, 1)
})
test('host root cap includes multiple owners and recovers after owner teardown', async () => {
const { roots, owner } = fixture()
const owners = Array.from({ length: 8 }, (_, i) => owner(String(i)))
for (const current of owners) for (let i = 0; i < 8; i++) roots.register(current, { path: `s${i}` })
assert.throws(() => roots.register(owner('overflow'), { path: 's' }), /limit/)
await Promise.all(owners.map(settled))
roots.forget(owners[0])
const replacement = owner('replacement')
roots.register(replacement, { path: 's' })
await settled(replacement)
assert.equal(replacement.roots.size, 1)
})
test('revoked owner cannot register and a late admitted root is withdrawn', async () => {
const { roots, calls, pending, owner } = fixture({ deferred: true })
const a = owner('a')
roots.register(a, { path: 'skills' })
a.state = 'disposed'
roots.forget(a)
assert.throws(() => roots.register(a, { path: 'late' }), /not live/)
pending.forEach(resolve => resolve())
await settled(a)
assert.equal(a.roots.size, 0)
assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1))
})
test('core refusal is preserved and local reservations retire with owner', async () => {
const { roots, owner } = fixture({ refused: 'reviewed bundle was changed' })
const a = owner('a')
roots.register(a, { path: 'skills' })
await settled(a)
assert.match(a.refusals[0], /skill_root.*reviewed bundle was changed/)
assert.equal(a.roots.size, 0)
roots.forget(a)
roots.register(a, { path: 'skills' })
await settled(a)
})
@@ -0,0 +1,4 @@
export const inject = ['skills']
export function apply(ctx: any) {
ctx.skills.registerRoot({ path: 'profiles/review-skills' })
}
@@ -0,0 +1,8 @@
{
"$schema": "https://agent-plugins.org/schemas/plugin.json",
"name": "skills-root",
"version": "0.1.0",
"description": "Test fixture: Native-reviewed disposable Skill roots reuse the existing catalog.",
"license": "MIT",
"extensions": { "net.codewhale": { "native": { "path": "index.mts" } } }
}
@@ -0,0 +1,5 @@
---
name: quick-check
description: Check one focused fact before making a claim
---
Inspect the exact source and report the verification boundary.