capabilities: read a plugin's where they live, stop copying them in

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
This commit is contained in:
2026-08-23 23:19:11 +00:00
co-authored by Claude Opus 5
parent 6572fa90f0
commit 8770f98dcd
15 changed files with 335 additions and 490 deletions
-97
View File
@@ -1,97 +0,0 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { itemsDir } from '@@/data-path';
import { publishTasks, taskSourceFor, unpublishTasks } from './publish';
import type { DiscoveredPlugin } from '../../plugins/manifest';
/**
* A plugin shipping a task is how it reaches the file browser's context menu, so these are the two
* properties that matter: it arrives on install, and uninstalling takes back only what the plugin brought.
*
* The second is the one worth a test. `tasks/` is the OWNER'S directory — their own work lives beside
* whatever a plugin published, with nothing on disk marking which is which. A `rm -rf` of the wrong scope
* here destroys work no plugin ever created, which is not a failure a type checker can catch.
*
* Everything is prefixed `zz-publish-test-` and removed afterwards, because these write into the real
* items store rather than a fixture — `itemsDir` is derived from the install layout and cannot be pointed
* elsewhere for a test.
*/
const PREFIX = 'zz-publish-test-';
const made: string[] = [];
const plugin = (dir: string): DiscoveredPlugin => ({ appName: `${PREFIX}plugin`, dir }) as DiscoveredPlugin;
const seedPluginWithTasks = (names: string[]): string => {
const dir = mkdtempSync(join(tmpdir(), 'zz-plugin-'));
made.push(dir);
for (const name of names) {
const t = join(dir, 'tasks', name);
mkdirSync(t, { recursive: true });
writeFileSync(join(t, 'TASK.md'), `---\nname: ${name}\n---\n`);
}
return dir;
};
afterEach(() => {
for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true });
// Anything this file could have written into the real store, by name.
for (const n of [`${PREFIX}one`, `${PREFIX}two`, `${PREFIX}owners-own`]) {
rmSync(join(itemsDir('tasks'), n), { recursive: true, force: true });
}
});
describe('publishTasks', () => {
test('a plugin with no tasks directory publishes nothing and does not fail', async () => {
const dir = mkdtempSync(join(tmpdir(), 'zz-plugin-'));
made.push(dir);
expect(await publishTasks(plugin(dir))).toBe(0);
});
test('tasks arrive in the items store, contents and all', async () => {
const dir = seedPluginWithTasks([`${PREFIX}one`, `${PREFIX}two`]);
expect(await publishTasks(plugin(dir))).toBe(2);
const landed = join(itemsDir('tasks'), `${PREFIX}one`, 'TASK.md');
expect(existsSync(landed)).toBe(true);
expect(readFileSync(landed, 'utf8')).toContain(`name: ${PREFIX}one`);
});
test('publishing twice overwrites rather than failing', async () => {
const dir = seedPluginWithTasks([`${PREFIX}one`]);
await publishTasks(plugin(dir));
expect(await publishTasks(plugin(dir))).toBe(1);
});
});
describe('unpublishTasks leaves the owner alone', () => {
// The whole reason this file exists.
test("removes only what the plugin shipped, never the owner's own task", async () => {
const ownersOwn = join(itemsDir('tasks'), `${PREFIX}owners-own`);
mkdirSync(ownersOwn, { recursive: true });
writeFileSync(join(ownersOwn, 'TASK.md'), '---\nname: hand written\n---\n');
const dir = seedPluginWithTasks([`${PREFIX}one`]);
await publishTasks(plugin(dir));
await unpublishTasks(plugin(dir));
expect(existsSync(join(itemsDir('tasks'), `${PREFIX}one`))).toBe(false);
expect(existsSync(join(ownersOwn, 'TASK.md'))).toBe(true);
});
test('a plugin whose source is already gone removes nothing', async () => {
const ownersOwn = join(itemsDir('tasks'), `${PREFIX}owners-own`);
mkdirSync(ownersOwn, { recursive: true });
// The directory the plugin was cloned into no longer exists — deleted before uninstall ran.
await unpublishTasks(plugin(join(tmpdir(), 'zz-plugin-does-not-exist')));
expect(existsSync(ownersOwn)).toBe(true);
});
test('the source path is the plugin directory, not the store', () => {
expect(taskSourceFor(plugin('/somewhere/plugins/x'))).toBe('/somewhere/plugins/x/tasks');
});
});
-73
View File
@@ -1,73 +0,0 @@
import { cp, readdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { itemsDir } from '@@/data-path';
import type { DiscoveredPlugin } from '../../plugins/manifest';
// Publishing a plugin's tasks, the fourth of these and the same shape as assets, affordances,
// notification catalogues and skills.
//
// ── Why a plugin needs to ship a task at all ──
//
// A task is how something reaches the FILE BROWSER'S CONTEXT MENU. `TriggerConfig` is
// `{ type: 'file'; extensions: string[] } | { type: 'directory' }`, and `matchesTrigger` in
// `officerdev/src/apps/FileBrowser/useTasks.ts` is what decides which entries appear on a right-click. So
// a plugin that wants to offer "transcribe this" on an mp3 does not need a platform change, a menu
// registry or a new frontend hook — it needs a task with the right trigger, and that already works.
//
// What was missing was only the delivery: every other publishable thing had a publisher and tasks did
// not, so the capability had to be installed by hand alongside the plugin and removed by hand after.
//
// ── Flat, not namespaced by publisher ──
//
// Same trade as skills, for the same reason and with the same limit. A task's directory name is its
// identity everywhere — `taskDirName` in a job row, the path a script runner materialises, the thing the
// owner sees in a run history. Wrapping that in `officerdev/whisper/` would put a publisher in every one
// of those places for the benefit of a collision nobody has had. Collisions resolve first-come, which is
// the rule the items store has always used.
//
// That is acceptable because a task claims no authority — it is an entry in a menu, gated by the same
// permission the tasks surface already carries. It is NOT acceptable for affordances, which is why those
// are namespaced: an affordance that could claim `officer/core:` would be claiming the platform's voice.
const tasksDir = () => itemsDir('tasks');
/** What the plugin ships, if anything. */
export const taskSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'tasks');
/** Publish a plugin's tasks. Returns the number of directories, or 0 when it ships none. */
export async function publishTasks(plugin: DiscoveredPlugin): Promise<number> {
const source = taskSourceFor(plugin);
if (!existsSync(source)) return 0;
await cp(source, tasksDir(), { recursive: true, force: true });
try {
return (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).length;
} catch {
return 0;
}
}
/**
* Drop a plugin's tasks.
*
* Removes only the directories THIS plugin shipped, read from its own source tree — never everything
* under `tasks/`, which holds the owner's own work. A task the owner wrote by hand has no marker saying
* so, so the plugin's source tree is the only honest record of what came from the plugin.
*
* The consequence is worth stating: if the owner EDITS a task a plugin shipped, uninstalling still
* removes it, because it is still one of the names the plugin published. That matches skills and is the
* behaviour to keep — the alternative is orphans nobody can attribute.
*/
export async function unpublishTasks(plugin: DiscoveredPlugin): Promise<void> {
const source = taskSourceFor(plugin);
if (!existsSync(source)) return;
let dirs: string[];
try {
dirs = (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return;
}
for (const dir of dirs) await rm(join(tasksDir(), dir), { recursive: true, force: true });
}