mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
Plumbing that makes Wintermute (and future downstream agents) day-1 usable on v0.15. Host repos drop a `gbrain.plugin.json` + `subagents/` directory somewhere, set GBRAIN_PLUGIN_PATH (colon-separated like \$PATH), and their custom subagent defs load at worker startup. Path policy is strict: absolute paths only. Relative, ~-prefixed, and URL-style (https://, file://) all rejected with warnings — the user controls where plugins live. Non-existent paths and files (not dirs) are warned and skipped so a typo doesn't crash worker startup. Collision policy: left-wins. If two plugins ship a subagent with the same name, the first one in GBRAIN_PLUGIN_PATH keeps it and the other gets a warning naming both sources. Deterministic + debuggable. Trust policy: plugins ship subagent defs ONLY. Cannot declare new tools, cannot extend the brain allow-list, cannot override safety flags. The subagent def's `allowed_tools:` frontmatter MUST subset the derived registry — validation happens at load time (worker startup), not at dispatch time, so a typo in a skill gives a loud startup error instead of silently "tool never fires at 3am." Manifest `plugin_version: "gbrain-plugin-v1"` locks the contract. Unknown versions rejected. `subagents` field escape attempts (`../../../etc` etc) rejected. gray-matter handles the markdown frontmatter parse — subagent defs don't conform to the page schema, so we don't use parseMarkdown. docs/guides/plugin-authors.md is the Wintermute-facing walkthrough. Covers the minimum viable plugin shape, the three policies, the frontmatter fields, known caveats (audit JSONL is local-only, tool calls always run remote=true, put_page is namespace-scoped). 22 unit tests pin path rejection, missing/invalid manifest, unsupported version, escape-attempt, basename fallback for missing frontmatter.name, allowed_tools round-trip, unknown-tool rejection with validAgentToolNames, empty env, multi-path, collision warning with left-wins, trimmed paths, manifest-rejection as warning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
9.0 KiB
TypeScript
254 lines
9.0 KiB
TypeScript
/**
|
|
* plugin-loader tests. Exercise the full path/manifest/validation surface
|
|
* using ephemeral tmp dirs so no repo content is touched.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import {
|
|
loadPluginsFromEnv,
|
|
loadSinglePlugin,
|
|
SUPPORTED_PLUGIN_VERSION,
|
|
__testing,
|
|
} from '../src/core/minions/plugin-loader.ts';
|
|
|
|
let tmpRoot: string;
|
|
|
|
beforeAll(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-loader-test-'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
for (const f of fs.readdirSync(tmpRoot)) {
|
|
fs.rmSync(path.join(tmpRoot, f), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Helper: build a plugin directory with a manifest + a subagents/ tree.
|
|
function writePlugin(
|
|
name: string,
|
|
opts: {
|
|
plugin_version?: string;
|
|
subagents?: Record<string, string>;
|
|
subagents_field?: string;
|
|
omit_manifest?: boolean;
|
|
bad_manifest_json?: boolean;
|
|
} = {},
|
|
): string {
|
|
const dir = path.join(tmpRoot, name);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
if (!opts.omit_manifest) {
|
|
const manifest = {
|
|
name,
|
|
version: '1.0.0',
|
|
plugin_version: opts.plugin_version ?? SUPPORTED_PLUGIN_VERSION,
|
|
...(opts.subagents_field ? { subagents: opts.subagents_field } : {}),
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(dir, 'gbrain.plugin.json'),
|
|
opts.bad_manifest_json ? '{not valid json' : JSON.stringify(manifest, null, 2),
|
|
);
|
|
}
|
|
|
|
if (opts.subagents) {
|
|
const sadir = path.join(dir, opts.subagents_field ?? 'subagents');
|
|
fs.mkdirSync(sadir, { recursive: true });
|
|
for (const [file, content] of Object.entries(opts.subagents)) {
|
|
fs.writeFileSync(path.join(sadir, file), content);
|
|
}
|
|
}
|
|
|
|
return dir;
|
|
}
|
|
|
|
describe('path policy', () => {
|
|
test('relative paths rejected', () => {
|
|
expect(__testing.rejectIfNotAbsolute('relative/path')).toMatch(/relative path rejected/);
|
|
});
|
|
|
|
test('~-prefixed paths rejected (no implicit expansion)', () => {
|
|
expect(__testing.rejectIfNotAbsolute('~/subagents')).toMatch(/~-prefixed/);
|
|
});
|
|
|
|
test('remote URLs rejected', () => {
|
|
expect(__testing.rejectIfNotAbsolute('https://example.com/plugins')).toMatch(/remote URL/);
|
|
expect(__testing.rejectIfNotAbsolute('file:///abs/p')).toMatch(/remote URL/);
|
|
});
|
|
|
|
test('absolute POSIX path accepted', () => {
|
|
expect(__testing.rejectIfNotAbsolute('/abs/path')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('loadSinglePlugin', () => {
|
|
test('loads a minimal manifest + one subagent def', () => {
|
|
const dir = writePlugin('wintermute', {
|
|
subagents: {
|
|
'meeting-ingestion.md': `---\nname: meeting-ingestion\nmodel: sonnet\n---\n\nYou are a meeting ingester.\n`,
|
|
},
|
|
});
|
|
const res = loadSinglePlugin(dir);
|
|
expect('error' in res).toBe(false);
|
|
if ('error' in res) return;
|
|
expect(res.manifest.name).toBe('wintermute');
|
|
expect(res.subagents.length).toBe(1);
|
|
expect(res.subagents[0]!.name).toBe('meeting-ingestion');
|
|
expect(res.subagents[0]!.body.trim()).toBe('You are a meeting ingester.');
|
|
});
|
|
|
|
test('missing manifest returns error', () => {
|
|
const dir = writePlugin('empty', { omit_manifest: true });
|
|
const res = loadSinglePlugin(dir);
|
|
expect('error' in res).toBe(true);
|
|
if ('error' in res) expect(res.error).toMatch(/missing gbrain\.plugin\.json/);
|
|
});
|
|
|
|
test('invalid manifest JSON returns error', () => {
|
|
const dir = writePlugin('bad-json', { bad_manifest_json: true });
|
|
const res = loadSinglePlugin(dir);
|
|
expect('error' in res).toBe(true);
|
|
if ('error' in res) expect(res.error).toMatch(/invalid manifest JSON/);
|
|
});
|
|
|
|
test('unsupported plugin_version rejected', () => {
|
|
const dir = writePlugin('future', { plugin_version: 'gbrain-plugin-v999' });
|
|
const res = loadSinglePlugin(dir);
|
|
expect('error' in res).toBe(true);
|
|
if ('error' in res) expect(res.error).toMatch(/unsupported plugin_version/);
|
|
});
|
|
|
|
test('escape-attempt subagents field rejected', () => {
|
|
const dir = writePlugin('escape', { subagents_field: '../../../etc' });
|
|
const res = loadSinglePlugin(dir);
|
|
expect('error' in res).toBe(true);
|
|
if ('error' in res) expect(res.error).toMatch(/escapes plugin root/);
|
|
});
|
|
|
|
test('falls back to file basename when frontmatter.name is missing', () => {
|
|
const dir = writePlugin('nameless', {
|
|
subagents: {
|
|
'implicit-name.md': `---\nmodel: sonnet\n---\nbody\n`,
|
|
},
|
|
});
|
|
const res = loadSinglePlugin(dir);
|
|
if ('error' in res) throw new Error(res.error);
|
|
expect(res.subagents[0]!.name).toBe('implicit-name');
|
|
});
|
|
|
|
test('allowed_tools frontmatter list of strings survives round-trip', () => {
|
|
const dir = writePlugin('tools', {
|
|
subagents: {
|
|
'researcher.md': `---\nname: researcher\nallowed_tools:\n - brain_search\n - brain_get_page\n---\nbody\n`,
|
|
},
|
|
});
|
|
const res = loadSinglePlugin(dir);
|
|
if ('error' in res) throw new Error(res.error);
|
|
expect(res.subagents[0]!.allowed_tools).toEqual(['brain_search', 'brain_get_page']);
|
|
});
|
|
|
|
test('allowed_tools referencing unknown tool names fails load', () => {
|
|
const dir = writePlugin('rogue', {
|
|
subagents: {
|
|
'typo.md': `---\nname: typo\nallowed_tools:\n - brain_seerch\n---\nbody\n`,
|
|
},
|
|
});
|
|
const res = loadSinglePlugin(dir, {
|
|
validAgentToolNames: new Set(['brain_search', 'brain_get_page']),
|
|
});
|
|
expect('error' in res).toBe(true);
|
|
if ('error' in res) expect(res.error).toMatch(/unknown tools: brain_seerch/);
|
|
});
|
|
|
|
test('validation passes when allowed_tools are all in the registry', () => {
|
|
const dir = writePlugin('clean', {
|
|
subagents: {
|
|
'ok.md': `---\nname: ok\nallowed_tools:\n - brain_search\n---\nbody\n`,
|
|
},
|
|
});
|
|
const res = loadSinglePlugin(dir, {
|
|
validAgentToolNames: new Set(['brain_search']),
|
|
});
|
|
expect('error' in res).toBe(false);
|
|
});
|
|
|
|
test('skipping validation (no validAgentToolNames) allows any allowed_tools', () => {
|
|
const dir = writePlugin('no-validate', {
|
|
subagents: {
|
|
'anything.md': `---\nname: anything\nallowed_tools:\n - tool_we_have_not_shipped_yet\n---\nbody\n`,
|
|
},
|
|
});
|
|
const res = loadSinglePlugin(dir);
|
|
expect('error' in res).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadPluginsFromEnv', () => {
|
|
test('empty env returns no plugins, no warnings', () => {
|
|
const r = loadPluginsFromEnv({ envPath: '' });
|
|
expect(r.plugins).toEqual([]);
|
|
expect(r.warnings).toEqual([]);
|
|
});
|
|
|
|
test('multi-path: colon-separated PATH loads both', () => {
|
|
const a = writePlugin('a', { subagents: { 'x.md': `---\nname: x\n---\nbody` } });
|
|
const b = writePlugin('b', { subagents: { 'y.md': `---\nname: y\n---\nbody` } });
|
|
const r = loadPluginsFromEnv({ envPath: `${a}:${b}` });
|
|
expect(r.plugins.length).toBe(2);
|
|
expect(r.plugins[0]!.manifest.name).toBe('a');
|
|
expect(r.plugins[1]!.manifest.name).toBe('b');
|
|
});
|
|
|
|
test('collision: left-wins with a warning', () => {
|
|
const left = writePlugin('left', { subagents: { 'shared.md': `---\nname: shared\n---\nleft body` } });
|
|
const right = writePlugin('right', { subagents: { 'shared.md': `---\nname: shared\n---\nright body` } });
|
|
const r = loadPluginsFromEnv({ envPath: `${left}:${right}` });
|
|
expect(r.plugins.length).toBe(2);
|
|
// Only the left plugin contributes the `shared` subagent.
|
|
const leftSubs = r.plugins[0]!.subagents.map(s => s.name);
|
|
const rightSubs = r.plugins[1]!.subagents.map(s => s.name);
|
|
expect(leftSubs).toContain('shared');
|
|
expect(rightSubs).not.toContain('shared');
|
|
expect(r.warnings.some(w => /collision.*shared/.test(w))).toBe(true);
|
|
});
|
|
|
|
test('non-existent path is warned + skipped', () => {
|
|
const r = loadPluginsFromEnv({ envPath: '/definitely/does/not/exist/here' });
|
|
expect(r.plugins.length).toBe(0);
|
|
expect(r.warnings.some(w => /does not exist/.test(w))).toBe(true);
|
|
});
|
|
|
|
test('relative path in env is warned + skipped', () => {
|
|
const r = loadPluginsFromEnv({ envPath: 'relative/dir' });
|
|
expect(r.plugins.length).toBe(0);
|
|
expect(r.warnings.some(w => /relative path rejected/.test(w))).toBe(true);
|
|
});
|
|
|
|
test('a file (not a directory) is warned + skipped', () => {
|
|
const file = path.join(tmpRoot, 'not-a-dir.txt');
|
|
fs.writeFileSync(file, 'x');
|
|
const r = loadPluginsFromEnv({ envPath: file });
|
|
expect(r.plugins.length).toBe(0);
|
|
expect(r.warnings.some(w => /not a directory/.test(w))).toBe(true);
|
|
});
|
|
|
|
test('trims whitespace around paths', () => {
|
|
const a = writePlugin('trimmed', { subagents: { 'x.md': `---\nname: x\n---\nbody` } });
|
|
const r = loadPluginsFromEnv({ envPath: ` ${a} ` });
|
|
expect(r.plugins.length).toBe(1);
|
|
});
|
|
|
|
test('manifest rejection shows up as a warning (not a throw)', () => {
|
|
const bad = writePlugin('futurep', { plugin_version: 'gbrain-plugin-v999' });
|
|
const r = loadPluginsFromEnv({ envPath: bad });
|
|
expect(r.plugins.length).toBe(0);
|
|
expect(r.warnings.some(w => /unsupported plugin_version/.test(w))).toBe(true);
|
|
});
|
|
});
|