Four publishers copied a plugin's affordances, notifications and skills into capabilities/ at install. That directory is meant to be forked — core's work plus the owner's — so a fork accumulated files it did not author with nothing on disk saying which, and there was no good answer: commit them and carry another plugin's work, leave them and git status stays dirty. Copying was also wrong in two ways unrelated to git. A copy goes stale, because plugins:update pulls the plugin's directory and the copy keeps describing the old version. And a copy survived plugins:disable, so a disabled plugin's skill stayed in the agent's index — off, and still being announced. Now the loaders take a list of roots: the owner's store, plus each installed AND enabled plugin's own directory. capability-sources.ts resolves that list. No namespace is needed to keep them apart, because separate directories do it structurally rather than by convention. The owner's own win a skill name collision — their root is first and the merge keeps the first of each id — so a plugin cannot take over a name already in use. Affordances are deliberately NOT de-duplicated: several can be true at once. Notifications needed more than a root list, and it would have failed silently. Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml. Read in place the path is just notifications/catalogue.yaml and carries no identity, so every plugin code would have vanished with nothing raised. loadPluginEntries takes the identity from the manifest instead, which is better provenance anyway — publisher and appName are validated at discovery, where a path was only ever a directory somebody chose. A yaml key still cannot claim another namespace, and that is pinned by test. createSession is async now, only because resolving enabled plugins reads the database. Once per session; its single caller already awaited. Deletes all four publishers including api/tasks/publish.ts from earlier today, superseded before it shipped. app-store/assets stays — images served over HTTP, where the copy is what makes them reachable. NOT done: tasks. That store is read-write, so reads must span sources while writes stay in the owner's directory, and a plugin's task has to be read-only in the UI. Next. Two things the design predicted as orphans turned out not to be, and both were checked before deleting rather than after. capabilities/extensions/tool-loader is written by the platform's own bootstrap every boot, not published by a plugin. And skills/email-db has no source to fall back on — the email plugin ships no skills directory, so that file exists in exactly one place and was hand-written. Neither was touched. 1422 pass, tsgo clean, officer restarts and serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
122 lines
5.3 KiB
TypeScript
122 lines
5.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { loadSkillsAcross } from './skills/load';
|
|
import { loadAffordancesAcross } from './affordances/load';
|
|
import { loadPluginEntries } from './notifications/catalogue';
|
|
|
|
/**
|
|
* A plugin's capabilities are READ where they live rather than copied into `capabilities/`
|
|
* (docs/plugin-capabilities.md). These pin the three properties that were not obvious.
|
|
*
|
|
* `capabilityRoots` itself is not tested here — it reads the real database and the real plugin
|
|
* directory, so a test of it would assert whatever this machine happens to have installed. What is worth
|
|
* pinning is what the loaders do with a list once they have one, and that is pure.
|
|
*/
|
|
|
|
const seed = (files: Record<string, string>): string => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'zz-caps-'));
|
|
for (const [rel, body] of Object.entries(files)) {
|
|
const abs = join(dir, rel);
|
|
mkdirSync(join(abs, '..'), { recursive: true });
|
|
writeFileSync(abs, body);
|
|
}
|
|
return dir;
|
|
};
|
|
|
|
const skill = (name: string, desc: string) => `---\nname: ${name}\ndescription: ${desc}\n---\n\nbody\n`;
|
|
|
|
describe('skills merge across sources', () => {
|
|
test('a plugin directory contributes its skills', () => {
|
|
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'the owner wrote this') });
|
|
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'a plugin shipped this') });
|
|
try {
|
|
const ids = loadSkillsAcross([owner, plugin]).map((s) => s.id).sort();
|
|
expect(ids).toEqual(['mine', 'theirs']);
|
|
} finally {
|
|
rmSync(owner, { recursive: true, force: true });
|
|
rmSync(plugin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Precedence, and the reason for it: a plugin must not be able to take over the meaning of a name its
|
|
// owner is already using, just by picking the same directory name.
|
|
test("the owner's own wins a name collision", () => {
|
|
const owner = seed({ 'deploy/SKILL.md': skill('Deploy', 'the owner version') });
|
|
const plugin = seed({ 'deploy/SKILL.md': skill('Deploy', 'the plugin version') });
|
|
try {
|
|
const found = loadSkillsAcross([owner, plugin]);
|
|
expect(found).toHaveLength(1);
|
|
expect(found[0]!.description).toBe('the owner version');
|
|
} finally {
|
|
rmSync(owner, { recursive: true, force: true });
|
|
rmSync(plugin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Which is what makes disable mean disable: the caller simply stops passing that root.
|
|
test('dropping a source drops its skills', () => {
|
|
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
|
|
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'gone when disabled') });
|
|
try {
|
|
expect(loadSkillsAcross([owner, plugin])).toHaveLength(2);
|
|
expect(loadSkillsAcross([owner])).toHaveLength(1);
|
|
} finally {
|
|
rmSync(owner, { recursive: true, force: true });
|
|
rmSync(plugin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a source that does not exist is skipped, not fatal', () => {
|
|
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
|
|
try {
|
|
expect(loadSkillsAcross([owner, join(tmpdir(), 'zz-caps-nope')])).toHaveLength(1);
|
|
} finally {
|
|
rmSync(owner, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('affordances are not de-duplicated', () => {
|
|
// Unlike skills: an affordance is guidance, several can be true at once, and two plugins describing the
|
|
// same surface differently is information rather than a collision.
|
|
test('both sources contribute even with the same file name', () => {
|
|
const a = seed({ 'x/one.md': '---\nname: A\ndescription: from a\n---\nbody a\n' });
|
|
const b = seed({ 'y/one.md': '---\nname: B\ndescription: from b\n---\nbody b\n' });
|
|
try {
|
|
expect(loadAffordancesAcross([a, b])).toHaveLength(2);
|
|
} finally {
|
|
rmSync(a, { recursive: true, force: true });
|
|
rmSync(b, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('notification codes come from the manifest, not the path', () => {
|
|
// The whole reason loadPluginEntries exists. Copied to capabilities/ the path carried
|
|
// <publisher>/<appName>; read in place it carries nothing, so the identity has to travel separately.
|
|
test('a plugin in its own directory still gets a namespaced code', () => {
|
|
const dir = seed({ 'notifications/catalogue.yaml': "nowPlaying:\n title: 'Now playing'\n" });
|
|
try {
|
|
const entries = loadPluginEntries({ appName: 'music', dir, manifest: { publisher: 'officerdev' } });
|
|
expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']);
|
|
expect(entries['officerdev/music:nowPlaying']!.thread).toBe('officerdev/music');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The property that must survive the move: a plugin cannot claim the platform's voice by writing a
|
|
// core-looking key into its own yaml.
|
|
test('a yaml key cannot claim another namespace', () => {
|
|
const dir = seed({ 'notifications/c.yaml': "officer/core:test:\n title: 'Nice try'\n" });
|
|
try {
|
|
const keys = Object.keys(loadPluginEntries({ appName: 'x', dir, manifest: { publisher: 'someone' } }));
|
|
expect(keys).toEqual(['someone/x:officer/core:test']);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|