mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Fix/sdk screenshot gate parity (#2164)
* fix(sdk): grade docs/screenshot.png exactly, like the registry gate * docs(wiki): document plugin MCP tools and the exact screenshot gate * test(sdk): cover the preflight screenshot probe
This commit is contained in:
@@ -25,7 +25,7 @@ import { listZipNames } from '../../zip.js';
|
||||
import { verifyAuthorSignature, checkSignatureShape, SignatureError } from '../verify-signature.js';
|
||||
import type { NetworkCheck, CheckContext, RegistryEntry, RegistryEntryVersion } from './types.js';
|
||||
import { pass, fail, skip } from './types.js';
|
||||
import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, images, undocumentedPermissions } from './readme.js';
|
||||
import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, undocumentedPermissions } from './readme.js';
|
||||
|
||||
export const DEFAULT_REGISTRY = 'liketrek/TREK-Plugins';
|
||||
|
||||
@@ -246,32 +246,26 @@ const readmeAtCommit: NetworkCheck = {
|
||||
const n = proseLength(md);
|
||||
if (n < MIN_PROSE_CHARS) problems.push(`• only ${n}/${MIN_PROSE_CHARS} chars of prose`);
|
||||
|
||||
// Resolve every image against the pinned commit. A relative path that exists in your tree
|
||||
// but was never committed 404s here — which is exactly the failure this catches.
|
||||
const imgs = images(md);
|
||||
if (!imgs.length) problems.push('• no screenshot (the registry requires at least one real image)');
|
||||
else {
|
||||
let anyOk = false;
|
||||
const reasons: string[] = [];
|
||||
for (const src of imgs) {
|
||||
const url = /^https?:\/\//.test(src)
|
||||
? src.includes('github.com') && src.includes('/blob/')
|
||||
? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
|
||||
: src
|
||||
: rawUrl(c.entry.repo, v.commitSha, src);
|
||||
try {
|
||||
const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } });
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (r.ok && ct.startsWith('image/')) {
|
||||
anyOk = true;
|
||||
break;
|
||||
}
|
||||
reasons.push(`${src} → ${r.status} ${ct || 'no content-type'}`);
|
||||
} catch {
|
||||
reasons.push(`${src} → unreachable`);
|
||||
}
|
||||
// The store cover, at the pinned commit. The registry's check-readme.mjs fetches EXACTLY
|
||||
// `docs/screenshot.png` here — that precise path is what the store card loads — so a file
|
||||
// that exists in your tree but was never committed 404s, which is exactly the failure this
|
||||
// catches. README image links are irrelevant to this gate; don't scan them.
|
||||
try {
|
||||
const r = await fetch(rawUrl(c.entry.repo, v.commitSha, 'docs/screenshot.png'), {
|
||||
headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' },
|
||||
});
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (!r.ok || !ct.startsWith('image/')) {
|
||||
problems.push(
|
||||
`• docs/screenshot.png does not resolve to an image at ${v.commitSha.slice(0, 8)} ` +
|
||||
`(got ${r.status} ${ct || 'no content-type'}) — this exact file is the store cover; ` +
|
||||
'run `trek-plugin shot`, commit it, and re-tag',
|
||||
);
|
||||
}
|
||||
if (!anyOk) problems.push('• no screenshot resolved to a real image:\n ' + reasons.join('\n '));
|
||||
} catch {
|
||||
problems.push(
|
||||
`• docs/screenshot.png is unreachable at ${v.commitSha.slice(0, 8)} — this exact file is the store cover`,
|
||||
);
|
||||
}
|
||||
|
||||
// Permission parity, against the manifest AT THE COMMIT (not the tree — they can differ).
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
missingSections,
|
||||
placeholders,
|
||||
proseLength,
|
||||
images,
|
||||
undocumentedPermissions,
|
||||
} from './readme.js';
|
||||
|
||||
@@ -405,44 +404,29 @@ const docsReadmeProse: OfflineCheck = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A screenshot that RESOLVES.
|
||||
* The store cover: `docs/screenshot.png`, ON DISK.
|
||||
*
|
||||
* The old check regexed the README for an image *link* and passed if it found one. The scaffold
|
||||
* writes `` and never creates docs/ — so the old check passed
|
||||
* on a link to a file that does not exist, and the author learned the truth from CI, after the
|
||||
* release was immutable. Resolve the path on disk instead.
|
||||
* Two generations of this check drifted lenient. The first regexed the README for an image
|
||||
* *link* and passed on the scaffold's dangling ``. The
|
||||
* second resolved README image paths — but the registry (check-readme.mjs, TREK-Plugins #40)
|
||||
* does not read the README for this gate at all: it fetches **exactly `docs/screenshot.png`**
|
||||
* at the pinned commit, because that precise path is what the store card loads
|
||||
* (`raw…/<commit>/docs/screenshot.png`). A README whose only images had other names went
|
||||
* green here and red in CI, after the release was immutable. Check the file the store reads.
|
||||
*/
|
||||
const docsScreenshot: OfflineCheck = {
|
||||
id: 'docs.screenshot',
|
||||
stage: 'docs',
|
||||
depth: 'offline',
|
||||
severity: 'error',
|
||||
title: 'README screenshot resolves to a real image',
|
||||
title: 'docs/screenshot.png exists (the store cover)',
|
||||
run: (c) => {
|
||||
if (c.readme === undefined) return skip();
|
||||
const imgs = images(c.readme);
|
||||
if (!imgs.length) {
|
||||
return fail(
|
||||
'no images in the README',
|
||||
'The store card shows this image, and the registry rejects a plugin without one. Capture a 16:9 shot\n' +
|
||||
'(1600×900 is ideal — the card crops the edges) and commit it as docs/screenshot.png.',
|
||||
'trek-plugin shot',
|
||||
);
|
||||
}
|
||||
const remote = imgs.filter((u) => /^https?:\/\//.test(u));
|
||||
const local = imgs.filter((u) => !/^https?:\/\//.test(u));
|
||||
const resolved = local.filter((u) => c.exists(u.replace(/^\.?\//, '')));
|
||||
|
||||
if (resolved.length) return pass(resolved[0]);
|
||||
// Nothing local resolves. A remote URL might still satisfy CI, but we cannot know offline —
|
||||
// don't claim a pass we haven't earned, and don't claim a failure we can't prove.
|
||||
if (remote.length) return pass(`${remote.length} remote image(s) — preflight verifies they resolve`);
|
||||
|
||||
const missing = local.filter((u) => !c.exists(u.replace(/^\.?\//, '')));
|
||||
if (c.exists('docs/screenshot.png')) return pass('docs/screenshot.png');
|
||||
return fail(
|
||||
`${missing.join(', ')} ${missing.length === 1 ? 'does' : 'do'} not exist`,
|
||||
'The README links this image but the file is not there, so the store card would be a broken image and the\n' +
|
||||
'registry rejects it. Capture a 16:9 shot (1600×900 is ideal — the card crops the edges).',
|
||||
'docs/screenshot.png does not exist',
|
||||
'The registry fetches exactly this path at the pinned commit — it is the store card image, and a README\n' +
|
||||
'that links other image names does not satisfy the gate. Capture a 16:9 shot (1600×900 is ideal — the\n' +
|
||||
'card crops the edges) and commit it as docs/screenshot.png.',
|
||||
'trek-plugin shot',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -85,13 +85,6 @@ export function proseLength(md: string): number {
|
||||
.trim().length;
|
||||
}
|
||||
|
||||
/** Every image src the README references — markdown and <img>, minus data: URIs (CI ignores those). */
|
||||
export function images(md: string): string[] {
|
||||
const mdImgs = [...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]);
|
||||
const htmlImgs = [...md.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
||||
return [...new Set([...mdImgs, ...htmlImgs])].filter((u) => !u.startsWith('data:'));
|
||||
}
|
||||
|
||||
/** Manifest permissions that are never named anywhere in the README. CI rejects these. */
|
||||
export function undocumentedPermissions(md: string, permissions: string[]): string[] {
|
||||
const lower = md.toLowerCase();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runOffline } from '../src/cli/checks/index.js';
|
||||
import { blocking, type CheckContext, type CheckReport } from '../src/cli/checks/types.js';
|
||||
import { proseLength, missingSections, placeholders, images, undocumentedPermissions } from '../src/cli/checks/readme.js';
|
||||
import { proseLength, missingSections, placeholders, undocumentedPermissions } from '../src/cli/checks/readme.js';
|
||||
|
||||
/** A manifest that passes everything, so each test can break exactly one thing. */
|
||||
const GOOD_MANIFEST = {
|
||||
@@ -188,19 +188,20 @@ describe('README checks', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The old check regexed for an image LINK and passed if it found one — so the scaffold's
|
||||
* ``, pointing at a file that was never created, satisfied
|
||||
* it. Resolve the path instead.
|
||||
* The registry's check-readme.mjs fetches EXACTLY docs/screenshot.png at the pinned commit —
|
||||
* README image links are irrelevant to this gate. (A previous version of this check scanned
|
||||
* them instead, which passed a README whose only images had other names and failed it in CI,
|
||||
* after the release was immutable.)
|
||||
*/
|
||||
it('fails a screenshot link whose file does not exist', () => {
|
||||
it('fails when docs/screenshot.png does not exist, whatever the README links', () => {
|
||||
const r = runOffline(ctx({ exists: (rel) => rel === 'server/index.js' }));
|
||||
expect(failed(r, 'docs.screenshot')).toBe(true);
|
||||
expect(r.errors.find((e) => e.id === 'docs.screenshot')?.next).toBe('trek-plugin shot');
|
||||
});
|
||||
|
||||
it('fails a README with no image at all', () => {
|
||||
it('passes on docs/screenshot.png alone — README image links are not what CI checks', () => {
|
||||
const r = runOffline(ctx({ readme: GOOD_README.replace('', '') }));
|
||||
expect(failed(r, 'docs.screenshot')).toBe(true);
|
||||
expect(failed(r, 'docs.screenshot')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,11 +260,6 @@ describe('README helpers mirror the registry exactly', () => {
|
||||
expect(performance.now() - started).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('ignores data: URIs when looking for a screenshot, like the registry does', () => {
|
||||
expect(images('')).toEqual([]);
|
||||
expect(images('<img src="shot.png">')).toEqual(['shot.png']);
|
||||
});
|
||||
|
||||
it('matches a permission anywhere in the README, case-insensitively', () => {
|
||||
expect(undocumentedPermissions('we use `DB:OWN` for caching', ['db:own'])).toEqual([]);
|
||||
expect(undocumentedPermissions('nothing here', ['db:own'])).toEqual(['db:own']);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* The network checks talk to GitHub only through the global `fetch`, so they run here against a
|
||||
* stubbed fetch and no network. These tests pin the README-at-commit gate — in particular its
|
||||
* screenshot half: the registry's check-readme.mjs fetches EXACTLY `docs/screenshot.png` at the
|
||||
* pinned commit (that precise path is what the store card loads), and preflight must grade the
|
||||
* same path the same way. The offline twin of this gate lives in checks.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { NETWORK_CHECKS } from '../src/cli/checks/network.js';
|
||||
import { makeContext } from '../src/cli/checks/context.js';
|
||||
import type { RegistryEntry } from '../src/cli/checks/types.js';
|
||||
|
||||
const readmeAtCommit = NETWORK_CHECKS.find((c) => c.id === 'network.readme-at-commit');
|
||||
if (!readmeAtCommit) throw new Error('network.readme-at-commit is not registered');
|
||||
|
||||
/** Passes every registry README rule: four sections, no placeholders, ≥400 chars of prose,
|
||||
* and the manifest's one permission (`db:own`) named. */
|
||||
const GOOD_README = `# Flight Tracker
|
||||
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
This plugin shows your next flight on the trip dashboard, so you never have to dig through your
|
||||
inbox for a boarding pass again. It reads the trip you are looking at, finds the next reservation
|
||||
of type flight, and renders it as a widget with the gate, the terminal and a countdown. There is
|
||||
nothing to configure and nothing to sign up for, and it works offline once the trip has synced.
|
||||
|
||||
## Screenshots
|
||||
|
||||
The image above shows the widget on a trip dashboard.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| \`db:own\` | Stores the cached flight lookup so the widget renders instantly. |
|
||||
|
||||
## Setup
|
||||
|
||||
Install it and enable it. There is nothing to configure.
|
||||
`;
|
||||
|
||||
const SHA = 'a'.repeat(40);
|
||||
|
||||
const ENTRY: RegistryEntry = {
|
||||
id: 'flight-tracker',
|
||||
name: 'Flight Tracker',
|
||||
author: 'You',
|
||||
description: 'Live flight status on the dashboard.',
|
||||
repo: 'you/trek-plugin-flight-tracker',
|
||||
type: 'widget',
|
||||
versions: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
gitTag: 'v1.0.0',
|
||||
commitSha: SHA,
|
||||
downloadUrl: 'https://github.com/you/trek-plugin-flight-tracker/releases/download/v1.0.0/plugin.zip',
|
||||
sha256: '0'.repeat(64),
|
||||
trek: '>=4.0.0 <5.0.0',
|
||||
size: 1234,
|
||||
apiVersion: 1,
|
||||
nativeModules: false,
|
||||
publishedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MANIFEST_AT_COMMIT = JSON.stringify({ id: 'flight-tracker', permissions: ['db:own'] });
|
||||
|
||||
type Route = { ok: boolean; status: number; contentType?: string; body?: string } | 'throw';
|
||||
|
||||
/** Serve each URL by suffix; anything unrouted is a test bug, not a silent 404. */
|
||||
function stubFetch(routes: Record<string, Route>): ReturnType<typeof vi.fn> {
|
||||
const fn = vi.fn(async (url: unknown, init?: { headers?: Record<string, string> }) => {
|
||||
void init;
|
||||
const key = Object.keys(routes).find((k) => String(url).endsWith(k));
|
||||
if (!key) throw new Error(`unexpected fetch in test: ${String(url)}`);
|
||||
const r = routes[key];
|
||||
if (r === 'throw') throw new Error('network down');
|
||||
return {
|
||||
ok: r.ok,
|
||||
status: r.status,
|
||||
headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? (r.contentType ?? null) : null) },
|
||||
text: async () => r.body ?? '',
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
const ctx = () => makeContext({ entry: ENTRY });
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('network.readme-at-commit — the README graded at the pinned commit', () => {
|
||||
it('passes a good README whose docs/screenshot.png serves an image at the commit', async () => {
|
||||
const fetchFn = stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': { ok: true, status: 200, contentType: 'image/png' },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('pass');
|
||||
|
||||
// The screenshot probe mirrors the registry gate byte-for-byte: the exact path at the
|
||||
// pinned commit, first 2 KB only.
|
||||
const shot = fetchFn.mock.calls.find(([u]) => String(u).endsWith('/docs/screenshot.png'));
|
||||
expect(shot?.[0]).toBe(`https://raw.githubusercontent.com/${ENTRY.repo}/${SHA}/docs/screenshot.png`);
|
||||
expect(shot?.[1]?.headers?.Range).toBe('bytes=0-2047');
|
||||
});
|
||||
|
||||
it('fails naming docs/screenshot.png when the file 404s at the commit — README links to other images do not count', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': { ok: false, status: 404, contentType: 'text/plain' },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/docs\/screenshot\.png does not resolve to an image/);
|
||||
expect(r.fix).toMatch(/404/);
|
||||
expect(r.fix).toMatch(/trek-plugin shot/);
|
||||
});
|
||||
|
||||
it('fails when the file resolves but is not an image (200 with no content-type)', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': { ok: true, status: 200 },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/no content-type/);
|
||||
});
|
||||
|
||||
it('fails calling the screenshot unreachable when the fetch itself throws', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': 'throw',
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/docs\/screenshot\.png is unreachable/);
|
||||
});
|
||||
|
||||
it('fails on a README missing at the commit — the tree is not what the registry grades', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: false, status: 404 },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.detail).toMatch(/README\.md not found/);
|
||||
});
|
||||
|
||||
it('reports the README problems and the permission parity together, like the registry does', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: '# Stub\n\nDescribe what it does. {{TAGLINE}}\n' },
|
||||
'/docs/screenshot.png': { ok: true, status: 200, contentType: 'image/png' },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/missing required section/);
|
||||
expect(r.fix).toMatch(/template placeholder/);
|
||||
expect(r.fix).toMatch(/chars of prose/);
|
||||
expect(r.fix).toMatch(/permissions not explained in the README: db:own/);
|
||||
});
|
||||
|
||||
it('skips without an entry — nothing to grade against', async () => {
|
||||
stubFetch({});
|
||||
const r = await readmeAtCommit.run(makeContext({}));
|
||||
expect(r.status).toBe('skip');
|
||||
});
|
||||
});
|
||||
@@ -797,6 +797,7 @@ module.exports = definePlugin({
|
||||
| `photoProvider.search(query, {page, limit}, ctx)` / `.getById(id, ctx)` | `hook:photo-provider` | **live** — plugin photo sources aggregated at `GET /api/plugin-photos/search` (+ `/sources`, `/item`) for the picker. Each `{id, title?, thumbnailUrl, fullUrl, takenAt?}`; thumbnail/full URLs must be http/https, per-source count capped, a failing source skipped |
|
||||
| `calendarSource.getName(ctx)` / `.getEvents(userId, start, end, ctx)` | `hook:calendar-source` | **live** — plugin calendar events aggregated for the signed-in user at `GET /api/plugin-calendar?start=&end=`. Each `{id, title, start, end, allDay}` (ISO dates); count capped, a failing source skipped |
|
||||
| `notificationChannel.send(msg, config, ctx)` / `.test(config, ctx)` | `hook:notification-channel` | **live** — registers a new notification channel. **Userless** (see below). See [Notification channels](#notification-channels) |
|
||||
| `mcpToolProvider.callTool({name, args}, ctx)` + `tools: string[]` | `mcp:tools` | **live** — publishes MCP tools on TREK's own MCP server, advertised to connected assistants as `plugin_<id>_<name>`. Runs **as the requesting MCP user** (route-like ctx). Declared in `capabilities.mcpTools`; only the intersection of the manifest declaration and the `tools` array is advertised. See [MCP tools](#mcp-tools) |
|
||||
|
||||
Each hook method receives its args plus the per-invocation `ctx`, so any `ctx.trips.*`
|
||||
read it makes is membership-checked against the current user (like a route handler) —
|
||||
@@ -887,6 +888,77 @@ Notes:
|
||||
addresses by default. It relaxes the policy for *every* installed plugin, so enable it only
|
||||
if you trust them all.
|
||||
|
||||
## MCP tools
|
||||
|
||||
A plugin can publish tools on **TREK's own MCP server**, so an assistant a user has
|
||||
connected to TREK (Claude, or any MCP client) can call into the plugin. Three parts,
|
||||
all required — miss one and nothing is advertised:
|
||||
|
||||
1. **`capabilities.mcpTools`** in the manifest — the declaration the admin consents
|
||||
to. Up to **8** tools, each `{ name, description, title?, inputSchema?,
|
||||
annotations? }`: `name` lowercase `^[a-z0-9_]{1,48}$` (unique, no dash/dot),
|
||||
`description` required, `inputSchema` an optional JSON Schema whose root `type`
|
||||
(if present) is `"object"`.
|
||||
2. **The `mcp:tools` permission** — the one hook grant not named `hook:*`.
|
||||
Declaring the capability without it fails `trek-plugin validate` *and* install.
|
||||
3. **`hooks.mcpToolProvider`** on the definition:
|
||||
|
||||
```js
|
||||
module.exports = definePlugin({
|
||||
hooks: {
|
||||
mcpToolProvider: {
|
||||
tools: ['pin_note', 'list_notes'], // plugin-local names
|
||||
async callTool({ name, args }, ctx) { // ONE function for all tools
|
||||
if (name === 'pin_note') { /* … */ }
|
||||
return { pinned: true } // any JSON value; the host builds the MCP envelope
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Semantics worth knowing before you ship one:
|
||||
|
||||
- **Only the intersection** of `capabilities.mcpTools[].name` and the code's
|
||||
`tools` array is advertised — **silently**. A name mismatch drops the tool with
|
||||
no warning anywhere, so a tool missing from `tools/list` is almost always these
|
||||
two lists disagreeing. Keep them identical.
|
||||
- **Names are prefixed** for assistants: `plugin_<id>_<name>`; `callTool` receives
|
||||
the local name. Global cap: 32 plugin tools across all installed plugins.
|
||||
- **`callTool` runs as the requesting MCP user** — route-like, never userless. The
|
||||
host binds the user from the MCP session (a plugin can't name one); user-scoped
|
||||
`ctx.*` reads are membership-checked against *that* user, and `mcp:tools` itself
|
||||
unlocks no ctx method — the tool can only do what your other grants allow.
|
||||
Timeout 15 s; demo users are refused before your code runs.
|
||||
- **Arguments are validated against your declared `inputSchema` before `callTool`
|
||||
runs.** `required` is enforced, `additionalProperties: false` rejects extras,
|
||||
enum/const/ranges/pattern/format are enforced — and nothing may be advertised
|
||||
that cannot be enforced, so an unsupported schema keyword (`oneOf`, `$ref`, an
|
||||
unknown `format`) **fails the install**, not silently drops. `default` is
|
||||
advertisement only — the host does not inject it; apply defaults yourself.
|
||||
- **A throw is a tool error, not a crash** — the assistant sees a sanitised
|
||||
message (≤ 300 chars). Results are capped at 64 KiB / 32 content blocks with a
|
||||
visible `[truncated: …]` marker; every string the assistant reads is sanitised
|
||||
(control characters and newlines collapsed, emoji stripped) so plugin text can't
|
||||
fake headings or instructions.
|
||||
- **Annotations are clamped against your grants**: `readOnlyHint: true` is lowered
|
||||
if the plugin holds any write-ish grant; `openWorldHint` is forced true if you
|
||||
hold any `http:outbound*`; `destructiveHint` defaults true unless read-only or
|
||||
explicitly `false`.
|
||||
- **Caller side:** the MCP client's token needs the **`plugins:use`** OAuth scope —
|
||||
deliberately coarse (not per-plugin or per-tool) and opt-in only; static `trek_`
|
||||
tokens and web-session JWTs have it implicitly. The scope grants no data access
|
||||
of its own: the plugin acts with the grants the **admin** consented to.
|
||||
- **The tool surface is frozen per MCP session.** Activating, deactivating,
|
||||
updating, re-trusting or uninstalling a plugin (or flipping an addon that
|
||||
affects one) closes every live MCP session; clients pick up the new surface on
|
||||
the next initialize. A dev-link **Reload** does *not* invalidate — reconnect
|
||||
your MCP client yourself after changing `tools`.
|
||||
|
||||
Admin → Plugins shows the tools a plugin will advertise. `trek-plugin dev` warns at
|
||||
load about a `mcpToolProvider` without the `mcp:tools` grant and 403s
|
||||
`/__dev/fire/hook/mcpToolProvider/callTool`, like any other hook.
|
||||
|
||||
## Settings-page actions
|
||||
|
||||
A plugin can put **buttons on its own settings page** — "Test connection", "Sync now",
|
||||
@@ -1266,6 +1338,7 @@ what your manifest declares.
|
||||
| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission, and is the only way to declare one with an empty `egress[]`. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). |
|
||||
| `capabilities.notificationChannel` | object | `{ title?, events? }` for a plugin implementing the `notificationChannel` hook — `title` names the column in the notification preferences matrix (default: the plugin's `name`), `events` **narrows** which events the channel carries (default: all ten plugin-deliverable events; `events` may only narrow that set). Requires the `hook:notification-channel` permission. See [Notification channels](#notification-channels). |
|
||||
| `capabilities.routeProfiles` | array | up to 3 `{ id, label, icon? }` entries for a plugin implementing the `routeProvider` hook — each becomes a selectable mode in the planner's route toggle (next to Driving/Walking). `id` is lowercase `[a-z][a-z0-9-]` (max 24 chars) and is what `getRoute` receives as `request.profile`; `label` (≤40 chars) is shown to the user. Requires the `hook:route-provider` permission. |
|
||||
| `capabilities.mcpTools` | array | up to **8** MCP tools for a plugin implementing the `mcpToolProvider` hook — each `{ name, description, title?, inputSchema?, annotations? }` (`name` lowercase `^[a-z0-9_]{1,48}$`, unique; `description` required; `inputSchema` a JSON Schema with root `type: "object"` built from a small enforced keyword set — an unsupported keyword fails the install). Requires the `mcp:tools` permission. See [MCP tools](#mcp-tools). |
|
||||
| `capabilities.provides` | string[] | function names this plugin exposes to its dependents via `ctx.plugins.call` (see [Talking to other plugins](#talking-to-other-plugins)). |
|
||||
| `capabilities.emits` | string[] | event names this plugin publishes to its dependents via `ctx.events.emit`. |
|
||||
| `requiredAddons` | string[] | addon ids that must be **enabled** for the plugin to activate (see [Dependencies](#dependencies)). |
|
||||
@@ -1299,10 +1372,10 @@ build arg defaults to the literal `dev` — has nothing to compare a range again
|
||||
the check is skipped and an unversioned build installs anything. Plugins should still
|
||||
guard optional `ctx.*` namespaces.
|
||||
|
||||
**Permissions** — the commonly-used core subset below; the **full list of 63**
|
||||
(all read/write scopes, the notify/ai/oauth brokers, every provider hook) lives in
|
||||
**[[Plugin Permissions|Plugin-Permissions]]**. Unknown values are rejected at install
|
||||
(and by `trek-plugin validate` and registry CI, which check against the same list).
|
||||
**Permissions** — the commonly-used core subset below; the **full list of 64**
|
||||
(all read/write scopes, the notify/ai/oauth brokers, every provider hook, `mcp:tools`)
|
||||
lives in **[[Plugin Permissions|Plugin-Permissions]]**. Unknown values are rejected at
|
||||
install (and by `trek-plugin validate` and registry CI, which check against the same list).
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
@@ -1339,6 +1412,7 @@ guard optional `ctx.*` namespaces.
|
||||
| `hook:user-data` | `deleteUserData` / `exportUserData` handlers — honour GDPR erasure (durable, retried) and data-export for a deleted/requesting user (userless; own db only) |
|
||||
| `hook:photo-provider` | `hooks.photoProvider` — a photo source for Memories, aggregated at `GET /api/plugin-photos/search` (see [Provider hooks](#provider-hooks)) |
|
||||
| `hook:calendar-source` | `hooks.calendarSource` — calendar events for the signed-in user, aggregated at `GET /api/plugin-calendar` (see [Provider hooks](#provider-hooks)) |
|
||||
| `mcp:tools` | `hooks.mcpToolProvider` — publish MCP tools on TREK's MCP server, declared in `capabilities.mcpTools`; runs as the requesting MCP user (see [MCP tools](#mcp-tools)) |
|
||||
|
||||
> There is **no `ws:broadcast:*`** — use `ws:broadcast:trip` and/or
|
||||
> `ws:broadcast:user` explicitly.
|
||||
|
||||
@@ -72,6 +72,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
|
||||
| `hook:trip-card-provider` | Add small **badges** to the dashboard trip cards via the `hooks.tripCardProvider` provider hook | Implement `TripCardProvider` in `hooks` — `getCards(tripIds, ctx)` is called ONCE with all the trip cards currently on the user's dashboard (each already access-checked for the acting user), returns `{tripId, id, label, value?, icon?, tone?, url?}[]`. **Declarative only** — plugin JS never runs on the dashboard. The host String-coerces + length-caps every field, enum-whitelists the tone, allowlists the url (http/https/mailto), caps the count (≤4 badges per trip, ≤240 total per plugin) and drops any badge whose `tripId` the dashboard didn't ask about; a failing provider is skipped. Exposed at `GET /api/trip-card-contributions?tripIds=…`. |
|
||||
| `hook:notification-channel` | Register a new **notification channel** (Gotify, Pushover, …) via the `hooks.notificationChannel` provider hook | Implement `NotificationChannel` in `hooks` — `send(msg, config, ctx)` receives a notification TREK has **already rendered** into the recipient's language (`{event, title, body, url?, tripName?}`) plus that recipient's own decrypted `scope:'user'` settings as `config`. Unlike every other hook this one is **host-initiated for an arbitrary recipient**, so it runs **userless**: `ctx.settings.get()` returns `undefined` and trip reads are refused — the recipient's credentials arrive as `config` precisely so the plugin never gains the right to read anything *as* them. Optional `test(config, ctx)` backs the "Send test" button. Declare the channel with `capabilities.notificationChannel: { title?, events? }`; `title` names the column in the preferences matrix (default: the plugin's name) and `events` may **narrow** which events it carries (default: every non-admin event — admin-scoped events are never deliverable to a plugin). Throw on failure: the host logs it and isolates it, so a dead channel can't stop the others. |
|
||||
| `hook:user-data` | Honour GDPR **data-subject rights** — erase and export the data the plugin stores about a user — via the `deleteUserData` / `exportUserData` handlers | Implement `deleteUserData({userId}, ctx)` and/or `exportUserData({userId}, ctx)` on the plugin definition (not on `ctx`). Both are **userless** (no acting user; the plugin only learns the `userId` and touches its OWN db). When a TREK account is deleted, the host queues an erasure for every plugin holding this grant and retries it **durably** until the plugin ACKs — even across restarts — so implement `deleteUserData` idempotently. `exportUserData` returns a JSON-serialisable value the host aggregates for an admin at `GET /api/admin/plugins/user-data/:userId/export`. |
|
||||
| `mcp:tools` | Publish **MCP tools** on TREK's own MCP server via the `hooks.mcpToolProvider` provider hook | Declare the tools in `capabilities.mcpTools` (≤8; name, required description, optional JSON-Schema `inputSchema` + MCP annotations) and implement `mcpToolProvider` in `hooks` — `tools: string[]` plus one `callTool({name, args}, ctx)` for all of them. Only the **intersection** of the manifest declaration and the `tools` array is advertised (silently — keep the lists identical), each as `plugin_<id>_<name>` to assistants holding the opt-in `plugins:use` OAuth scope. `callTool` runs **as the requesting MCP user** (route-like; membership-checked reads, 15 s timeout) and this grant unlocks no `ctx` method of its own — what a tool can do is set by the plugin's other grants. Arguments are validated against the declared schema before the plugin runs; results are capped (64 KiB / 32 blocks) and every string sanitised; annotation hints are clamped against the plugin's grants (`readOnlyHint` can only be lowered, `openWorldHint` is forced true with any `http:outbound*`). Any lifecycle/grant change closes live MCP sessions so the surface is re-advertised. The only hook permission not named `hook:*`. |
|
||||
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
|
||||
|
||||
## Outbound network — `http:outbound` vs `http:outbound:<host>`
|
||||
@@ -160,7 +161,7 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]].
|
||||
|---|---|
|
||||
| `trek-plugin create [name]` | Scaffold a plugin. With no name it runs an interactive wizard (id, type, author, permissions); with a name it takes `--type`/`--author`/`--permissions` flags. |
|
||||
| `trek-plugin dev [dir]` | Run the plugin locally with a real request loop + hot reload — no full TREK. The injected `ctx` enforces your granted permissions, `db:own` is a real SQLite file, routes serve under `/api/<path>`, and page/widget UI at `/ui`. |
|
||||
| `trek-plugin validate [dir]` | Manifest + layout checks, and the gate for publishing: parses the manifest with the same rules as install, then runs every registry gate that can be answered from the working tree — exiting non-zero on any of them. It **fails** if `README.md` is missing, is missing one of the four required sections, has too little prose, still holds template placeholders, does not explain every permission the manifest declares, or has no usable screenshot (no image at all, or every local image path is missing on disk — a remote `https://` image passes here and is verified later by `preflight`); it also fails if `server/index.js` was never built. Failures that only the registry cares about do **not** block `trek-plugin pack`, so the dev loop keeps working while the docs are still a stub — but they do stop `publish`, which runs these same checks first and refuses before anything is packed, tagged or released. A handful of checks are *warnings* that never fail the command: directory name ≠ plugin id, an unbounded `trek` range, an unknown required addon, emoji in manifest text, an undeclared egress host, and a page/widget not using the design kit. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. |
|
||||
| `trek-plugin validate [dir]` | Manifest + layout checks, and the gate for publishing: parses the manifest with the same rules as install, then runs every registry gate that can be answered from the working tree — exiting non-zero on any of them. It **fails** if `README.md` is missing, is missing one of the four required sections, has too little prose, still holds template placeholders, does not explain every permission the manifest declares, or is missing `docs/screenshot.png` (the exact file the store card loads — README image links pointing anywhere else do not satisfy the gate; `trek-plugin shot` writes it, and `preflight` re-verifies it at the pinned commit); it also fails if `server/index.js` was never built. Failures that only the registry cares about do **not** block `trek-plugin pack`, so the dev loop keeps working while the docs are still a stub — but they do stop `publish`, which runs these same checks first and refuses before anything is packed, tagged or released. A handful of checks are *warnings* that never fail the command: directory name ≠ plugin id, an unbounded `trek` range, an unknown required addon, emoji in manifest text, an undeclared egress host, and a page/widget not using the design kit. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. |
|
||||
| `trek-plugin preflight --repo <o/n> --tag <vX>` | Runs the **full** registry CI checks locally over the network (tag→commit, manifest parity, artifact sha256/size, native scan, README quality gate) against your pushed release — so you catch a CI failure before opening the PR. |
|
||||
| `trek-plugin submit --repo <o/n> --tag <vX>` | Opens the registry PR for you: forks TREK-Plugins, fast-forwards the fork, branches off the registry's current main, writes/merges `registry/plugins/<id>.json`, pushes, and creates the PR. Requires `gh`. |
|
||||
| `trek-plugin publish --repo <o/n> --tag <vX>` | **The one-command release**: local gates → pack → tag + GitHub release → preflight → open the registry PR. Nothing is packed, tagged or released until the local gates pass (`--no-checks` skips them). A failure after the release is cut rolls back the tag and release that run created, so the same tag can be re-used (`--keep-release` opts out). Add `--sign` to sign it; `--sign --allow-key-change` rotates your signing key as part of the release. Requires `git` + `gh`. |
|
||||
|
||||
@@ -94,8 +94,9 @@ Between them they now catch, **offline**, nearly everything the registry rejects
|
||||
typo is invisible locally — but CI rejects it)
|
||||
- a README missing any of the four required sections, still carrying scaffold
|
||||
placeholders, or under **400 characters** of real prose
|
||||
- a screenshot that doesn't resolve to a file on disk (not just a link in the README —
|
||||
the file has to be there)
|
||||
- a missing `docs/screenshot.png` — the registry fetches **exactly that path** at the
|
||||
pinned commit (it is what the store card loads), so a README link to any other image
|
||||
name doesn't count; the file has to be there
|
||||
- a permission your manifest declares but the README never explains
|
||||
- a `name`, `description` or `author` outside the registry's length limits, or a name
|
||||
that mixes Latin with Cyrillic/Greek look-alikes (a homoglyph spoof)
|
||||
@@ -103,8 +104,10 @@ Between them they now catch, **offline**, nearly everything the registry rejects
|
||||
the network allow-list and the iframe CSP from those permissions only and never reads
|
||||
`egress[]`, so such a host is silently unreachable at runtime
|
||||
- a permission TREK doesn't know — the SDK and registry CI check against the same
|
||||
63-entry list the host enforces, so a typo'd permission fails here instead of at
|
||||
install on every instance
|
||||
64-entry list the host enforces, so a typo'd permission fails here instead of at
|
||||
install on every instance (the registry's copy is a manually regenerated snapshot,
|
||||
so a brand-new TREK permission can briefly fail registry CI even though `validate`
|
||||
accepts it — that's the snapshot lagging, not a typo)
|
||||
|
||||
Only **six** gates genuinely need the network, because none of them can be answered from
|
||||
your working tree: that the tag resolves to the commit the entry pins, that the manifest at
|
||||
@@ -280,11 +283,12 @@ binaries** in the archive · `egress[]` present (and no bare `*`) when
|
||||
|
||||
**README** (`check-readme.mjs`, fetched at the pinned commit): must exist at the
|
||||
repo root, contain the sections **What it does / Screenshots / Permissions /
|
||||
Setup**, carry **at least one screenshot that resolves to a real image**
|
||||
(a relative `docs/screenshot.png` is resolved against the commit), have real
|
||||
prose (**≥ 400 characters** after stripping headings/code/images/tables), contain
|
||||
no leftover scaffold placeholders, and **explain every permission** your manifest
|
||||
declares (each permission string must appear in the README).
|
||||
Setup**, and **`docs/screenshot.png` must resolve to a real image at that
|
||||
commit** — the gate fetches exactly that path, because it is what the store card
|
||||
loads; README links to other image names do not satisfy it. The README must also
|
||||
have real prose (**≥ 400 characters** after stripping headings/code/images/tables),
|
||||
contain no leftover scaffold placeholders, and **explain every permission** your
|
||||
manifest declares (each permission string must appear in the README).
|
||||
|
||||
## Provenance & integrity
|
||||
|
||||
|
||||
@@ -53,6 +53,26 @@ Users find them under **Settings → Plugins**, beneath that plugin's fields. An
|
||||
**as the user who clicked it**, so a "Test connection" checks *their* credentials and can
|
||||
never see anyone else's. TREK refuses any action the plugin didn't declare in its manifest.
|
||||
|
||||
### Plugin MCP tools
|
||||
|
||||
A plugin (of any type) can publish **tools on TREK's own MCP server**, so an AI
|
||||
assistant a user has connected to TREK can call into it — declared in the manifest
|
||||
(`capabilities.mcpTools`) behind the **`mcp:tools`** permission, both part of what
|
||||
you consent to at install. Three guarantees hold:
|
||||
|
||||
- **You see the surface before it exists.** Admin → Plugins shows exactly which
|
||||
tools a plugin will advertise (they appear to assistants as
|
||||
`plugin_<id>_<name>`), and any activate/deactivate/update/uninstall closes open
|
||||
assistant sessions so the surface is re-read.
|
||||
- **A tool call runs as the requesting user.** The host binds the user from the
|
||||
assistant's session — a plugin can never pick one — so every read is
|
||||
membership-checked exactly like the plugin's normal API calls, and the
|
||||
`mcp:tools` grant itself unlocks no data: the tool can only do what the
|
||||
plugin's *other* granted permissions allow.
|
||||
- **Assistants must opt in.** An OAuth client only reaches plugin tools with the
|
||||
**`plugins:use`** scope, which is never granted by default — the user approves
|
||||
it explicitly on the consent screen.
|
||||
|
||||
### Trip-page plugins: placement and tab takeover
|
||||
|
||||
A **trip-page** plugin normally adds its tab *after* the planner's built-in tabs.
|
||||
|
||||
Reference in New Issue
Block a user