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
-55
View File
@@ -17,10 +17,6 @@ import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
import { publishSkills, unpublishSkills } from '../skills/publish';
import { publishTasks, unpublishTasks } from '../api/tasks/publish';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
import { generatePluginSchemas, pushPluginSchema } from './schema';
@@ -403,53 +399,6 @@ export async function installPlugin(
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
}
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
// repository, copied to a platform-managed place.
//
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
// over it would cost the owner the plugin.
try {
const published = await publishAffordances(plugin);
if (published) {
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
}
} catch (err) {
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
try {
const published = await publishNotifications(plugin);
if (published) {
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
}
} catch (err) {
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
// not learn the reference exists, which is the state every plugin was in before this.
try {
const published = await publishSkills(plugin);
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
} catch (err) {
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
}
// And the same for tasks, which is how a plugin reaches the FILE BROWSER'S CONTEXT MENU. A task with a
// file trigger appears on a right-click with no platform change at all — `matchesTrigger` in
// officerdev/src/apps/FileBrowser/useTasks.ts already does that work. What was missing was only the
// delivery, so the capability had to be installed by hand beside the plugin and removed by hand after.
try {
const published = await publishTasks(plugin);
if (published) await step(steps, onStep, `tasks: ${published} published to capabilities/tasks/`);
} catch (err) {
await step(steps, onStep, `tasks: NOT published — ${err instanceof Error ? err.message : err}`);
}
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
if (plugin.web && forUserId !== undefined) {
@@ -547,12 +496,8 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
// worse than none, because it reads as true.
if (plugin) await unpublishAffordances(plugin).catch(() => {});
// And its notification codes, which stop resolving rather than resolving to something stale. A device
// still holding a queued push renders the unknown-code fallback, which is a defined state.
if (plugin) await unpublishNotifications(plugin).catch(() => {});
if (plugin) await unpublishSkills(plugin).catch(() => {});
if (plugin) await unpublishTasks(plugin).catch(() => {});
await step(steps, onStep, 'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)');
return { ok: true, appName, steps };
}