Files
gbrain/test/operations-descriptions.test.ts
T
ca13f40820 v0.41.36.0 feat(mcp): publish agent skills (list_skills / get_skill) for thin clients (#1661)
* feat(skill-catalog): host-repo skill catalog core + mcp config keys

New src/core/skill-catalog.ts resolves the agent repo's skills dir, builds a
flat catalog, fetches one skill's prose, and gates publishing. Path confinement
(manifest-vetted lookup + realpath + SKILL.md file-type check), 256KB response
cap, frontmatter allowlist, and D7 tool cross-reference live here. config.ts
gains the mcp.publish_skills / mcp.skills_dir keys (+ KNOWN_CONFIG entries).

* feat(mcp): list_skills + get_skill read ops for thin-client skill discovery

Two read-scope, non-localOnly ops (dynamic-import skill-catalog to avoid the
cycle) let Codex/Claude Code/Perplexity discover and follow the agent's skills
over gbrain serve. Descriptions + the instructional envelope constants are
pinned in operations-descriptions.ts.

* feat(mcp): default new installs to publish skills; consent prompt on upgrade

gbrain init writes mcp.publish_skills:true (file plane) so new installs publish
by default. gbrain upgrade prompts existing installs once (DB plane), strongly
recommending opt-in, showing the resolved skills dir + that SKILL.md contents
become readable by authorized remote MCP callers.

* test(skill-catalog): catalog, security-confinement, transport-gate, description pins

40 cases: buildSkillCatalog/getSkillDetail/D7 split (skill-catalog.test.ts),
path traversal + symlink + poisoned-manifest + oversize + non-SKILL.md +
gate (skill-catalog-security.test.ts), and real dispatchToolCall gate+scope+plane
coverage (skill-catalog-transports.test.ts). Plus list_skills/get_skill
description + envelope pins.

* chore: bump version and changelog (v0.41.36.0)

MCP skill catalog (list_skills / get_skill) — thin clients can discover and
follow the agent repo's skills over gbrain serve. PR2 (tarball/install) filed
in TODOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document skill-catalog (list_skills/get_skill + mcp config keys) for v0.41.36.0

Add the src/core/skill-catalog.ts Key-files annotation to CLAUDE.md covering the
two new read-scope MCP ops, the mcp.publish_skills / mcp.skills_dir config keys,
the full trust-boundary mitigation stack, and the init/upgrade wiring.
Regenerate llms-full.txt to match (CI build-llms drift gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:11:25 -07:00

190 lines
8.1 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import {
GET_RECENT_SALIENCE_DESCRIPTION,
FIND_ANOMALIES_DESCRIPTION,
GET_RECENT_TRANSCRIPTS_DESCRIPTION,
LIST_PAGES_DESCRIPTION,
QUERY_DESCRIPTION,
SEARCH_DESCRIPTION,
LIST_SKILLS_DESCRIPTION,
GET_SKILL_DESCRIPTION,
SKILL_CATALOG_INSTRUCTIONS,
SKILL_CLIENT_GUIDANCE,
} from '../src/core/operations-descriptions.ts';
import { operations, operationsByName } from '../src/core/operations.ts';
import { BRAIN_TOOL_ALLOWLIST } from '../src/core/minions/tools/brain-allowlist.ts';
/**
* Tool descriptions are LLM-facing strings that drive routing. v0.29 adds
* three new ops + redirects on three existing ones. These tests pin the
* key phrases that the routing decision depends on so accidental edits
* (description rewrites, AI cleanup, voice changes) fail CI.
*/
describe('v0.29 — get_recent_salience description', () => {
test('matches the operation registration', () => {
expect(operationsByName['get_recent_salience'].description).toBe(GET_RECENT_SALIENCE_DESCRIPTION);
});
test('contains the explicit "Use this when" trigger phrase', () => {
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("Use this when the user asks");
});
test('lists the personal-query trigger keywords', () => {
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("what's been going on");
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("anything crazy happening");
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("notable");
});
test('explicitly bans semantic search for the same intent', () => {
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("Do NOT run a semantic search for these");
});
});
describe('v0.29 — find_anomalies description', () => {
test('matches the operation registration', () => {
expect(operationsByName['find_anomalies'].description).toBe(FIND_ANOMALIES_DESCRIPTION);
});
test('mentions the cohort framing', () => {
expect(FIND_ANOMALIES_DESCRIPTION).toContain("grouped by cohort");
expect(FIND_ANOMALIES_DESCRIPTION).toContain("(tag or type)");
});
test('lists the unusual / what-stood-out trigger phrases', () => {
expect(FIND_ANOMALIES_DESCRIPTION).toContain("stood out");
expect(FIND_ANOMALIES_DESCRIPTION).toContain("unusual");
});
test('does not promise year-cohort behavior (deferred to v0.30)', () => {
// v0.29 ships tag + type only. The phrase below confirms the description
// does not lie about coverage — surfacing year would route the LLM to
// call the op for date-bucket questions it can't actually serve.
expect(FIND_ANOMALIES_DESCRIPTION).toContain("Cohort kinds: tag, type");
});
});
describe('v0.29 — get_recent_transcripts description', () => {
test('matches the operation registration', () => {
expect(operationsByName['get_recent_transcripts'].description).toBe(GET_RECENT_TRANSCRIPTS_DESCRIPTION);
});
test('mandates priority over query/search for personal questions', () => {
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("FIRST");
});
test('explains raw vs polished distinction', () => {
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("NOT polished");
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("canonical source");
});
test('discloses the local-only constraint to the LLM', () => {
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("Local-only");
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("permission_denied");
});
});
describe('v0.29 — redirect hints on existing ops', () => {
test('list_pages mentions sort=updated_desc as the recency-question answer', () => {
expect(operationsByName['list_pages'].description).toBe(LIST_PAGES_DESCRIPTION);
expect(LIST_PAGES_DESCRIPTION).toContain("sort=updated_desc");
expect(LIST_PAGES_DESCRIPTION).toContain("what did I touch this week");
});
test('query redirects personal/emotional queries to the v0.29 ops', () => {
expect(operationsByName['query'].description).toBe(QUERY_DESCRIPTION);
expect(QUERY_DESCRIPTION).toContain("get_recent_salience");
expect(QUERY_DESCRIPTION).toContain("find_anomalies");
expect(QUERY_DESCRIPTION).toContain("get_recent_transcripts");
});
test('query warns the LLM not to assume "crazy" means impressive', () => {
expect(QUERY_DESCRIPTION).toContain("Do NOT assume");
expect(QUERY_DESCRIPTION).toContain("difficult or emotionally charged");
});
test('search has the shorter redirect hint', () => {
expect(operationsByName['search'].description).toBe(SEARCH_DESCRIPTION);
expect(SEARCH_DESCRIPTION).toContain("get_recent_salience");
});
});
describe('v0.29 — subagent allow-list', () => {
test('includes get_recent_salience and find_anomalies', () => {
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_salience')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('find_anomalies')).toBe(true);
});
test('excludes get_recent_transcripts (codex C3 — would be a remote=true footgun)', () => {
// The op throws permission_denied for remote=true callers, and all subagent
// calls run with remote=true. Including it in the allow-list would mean
// every subagent call to it returns an error — looks like a bug.
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_transcripts')).toBe(false);
});
test('all v0.29 ops in the allow-list resolve to a registered Operation', () => {
// brain-allowlist invariant: every name maps to an entry in operations.ts
// OPERATIONS array. This guard catches rename drift.
for (const name of ['get_recent_salience', 'find_anomalies']) {
expect(operationsByName[name]).toBeDefined();
}
});
test('list_pages has new sort + updated_after params surfaced to MCP', () => {
const op = operationsByName['list_pages'];
expect(op.params.sort).toBeDefined();
expect(op.params.updated_after).toBeDefined();
});
});
describe('v0.29 — operations array carries the three new ops', () => {
test('all three are registered (one allow-listed pair + one local-only)', () => {
const names = operations.map(o => o.name);
expect(names).toContain('get_recent_salience');
expect(names).toContain('find_anomalies');
expect(names).toContain('get_recent_transcripts');
});
});
describe('PR1 — skill catalog descriptions', () => {
test('list_skills / get_skill match the operation registration', () => {
expect(operationsByName['list_skills'].description).toBe(LIST_SKILLS_DESCRIPTION);
expect(operationsByName['get_skill'].description).toBe(GET_SKILL_DESCRIPTION);
});
test('descriptions teach "prose, not executable code" + the follow-the-prose protocol', () => {
expect(LIST_SKILLS_DESCRIPTION).toContain('NOT executable code');
expect(LIST_SKILLS_DESCRIPTION).toContain('get_skill');
expect(GET_SKILL_DESCRIPTION).toContain('nothing to');
expect(GET_SKILL_DESCRIPTION).toContain('same-named MCP tool');
});
test('descriptions surface usable/unavailable tool honesty', () => {
expect(LIST_SKILLS_DESCRIPTION).toContain('usable_tools');
expect(LIST_SKILLS_DESCRIPTION).toContain('unavailable_tools');
expect(GET_SKILL_DESCRIPTION).toContain('unavailable_tools');
});
test('both ops are read-scope, non-localOnly (thin clients reach them over HTTP)', () => {
for (const n of ['list_skills', 'get_skill']) {
expect(operationsByName[n].scope).toBe('read');
expect(operationsByName[n].localOnly).toBeFalsy();
expect(operationsByName[n].mutating).toBeFalsy();
}
});
test('instructions envelope is shaped + load-bearing', () => {
expect(SKILL_CATALOG_INSTRUCTIONS.summary).toContain('not executable tools');
expect(SKILL_CATALOG_INSTRUCTIONS.how_to_use.length).toBeGreaterThanOrEqual(3);
expect(SKILL_CATALOG_INSTRUCTIONS.how_to_use.join(' ')).toContain('get_skill');
expect(SKILL_CLIENT_GUIDANCE.nature).toContain('not code to execute');
expect(SKILL_CLIENT_GUIDANCE.protocol.length).toBeGreaterThanOrEqual(3);
});
test('operations array carries both new ops', () => {
const names = operations.map(o => o.name);
expect(names).toContain('list_skills');
expect(names).toContain('get_skill');
});
});