From 85d63f2202cf69e651f8b79fdc777a178f484538 Mon Sep 17 00:00:00 2001 From: dazhi Date: Tue, 22 Sep 2026 22:21:19 +0800 Subject: [PATCH 01/14] chore: add zero-dependency check toolchain with contract constants and path rules --- package-lock.json | 16 ++++++ package.json | 16 ++++++ scripts/lib/contract.mjs | 107 +++++++++++++++++++++++++++++++++++++++ scripts/lib/fs.mjs | 44 ++++++++++++++++ scripts/lib/paths.mjs | 53 +++++++++++++++++++ scripts/lib/report.mjs | 22 ++++++++ test/paths.test.mjs | 44 ++++++++++++++++ test/report.test.mjs | 24 +++++++++ 8 files changed, 326 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/lib/contract.mjs create mode 100644 scripts/lib/fs.mjs create mode 100644 scripts/lib/paths.mjs create mode 100644 scripts/lib/report.mjs create mode 100644 test/paths.test.mjs create mode 100644 test/report.test.mjs diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7bd1e84 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "minimax-code-miniapps", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minimax-code-miniapps", + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=22" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a4a1143 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "minimax-code-miniapps", + "version": "0.1.0", + "private": true, + "description": "Community Mini App packages for MiniMax Code, with the contract checks contributors run before opening a pull request.", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "validate": "node scripts/validate.mjs", + "test": "node --test \"test/**/*.test.mjs\"", + "check": "npm run validate && npm test" + }, + "license": "MIT" +} diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs new file mode 100644 index 0000000..361c237 --- /dev/null +++ b/scripts/lib/contract.mjs @@ -0,0 +1,107 @@ +// Constants shared by every rule. Values mirror the MiniMax Code package reader. + +export const HOST_VERSION = '3.0.73'; + +export const MANIFEST_FIELDS = new Set([ + '$schema', + 'schemaVersion', + 'name', + 'displayName', + 'version', + 'description', + 'author', + 'icon', + 'darkIcon', + 'category', + 'exampleQueries', + 'apps', + 'mcpServers', + 'skills', + 'hooks', + 'hostBindings', +]); + +export const PLUGIN_NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; +export const PLUGIN_NAME_MAX_LENGTH = 80; +export const VERSION_MAX_LENGTH = 128; +export const REFERENCE_MAX_LENGTH = 512; + +export const SEMVER = + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u; + +export const ICON_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.(?:png|jpe?g|webp)$/u; +export const APP_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.app\.json$/u; +export const MCP_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.mcp\.json$/u; +export const SKILL_PATH = /^skills\/[A-Za-z0-9._-]+\/SKILL\.md$/u; +export const HOOK_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.json$/u; +export const HOST_BINDING_PATH = /^bindings\/[A-Za-z0-9._-]+\.binding\.json$/u; + +export const CATEGORIES = [ + 'Office', + 'Studio', + 'Design & Sites', + 'Code', + 'Business', + 'Sales', + 'Productivity', + 'Science & Healthcare', + 'Education', + 'Other', +]; + +export const MCODE_EXPECTED = Object.freeze({ + schemaVersion: 2, + miniApp: './miniapp/miniapp.json', +}); + +export const MINIAPP_FIELDS = new Set([ + 'schemaVersion', + 'artifacts', + 'runtime', + 'surface', + 'mcpEndpoints', + 'hostConnectorAccess', +]); +export const ARTIFACT_FIELDS = new Set(['client', 'node']); +export const RUNTIME_FIELDS = new Set(['kind', 'entry', 'lifecycle']); +export const SURFACE_FIELDS = new Set(['path']); +export const MCP_ENDPOINT_FIELDS = new Set(['server', 'path']); +export const HOST_CONNECTOR_ACCESS_FIELDS = new Set(['providers']); +export const MCP_SERVER_NAME = /^[a-zA-Z0-9_-]{1,128}$/u; +export const CONNECTOR_PROVIDER = /^[a-z0-9_-]{1,64}$/u; +export const PAYLOAD_DIRECTORY = 'miniapp'; +export const ENTRY_EXTENSION = /\.(?:[cm]?js)$/u; +export const EXCLUDED_DIRECTORY = 'node_modules'; + +export const PORTABLE_SEGMENT = /^[A-Za-z0-9._-]+$/u; +export const WINDOWS_RESERVED = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + ...Array.from({ length: 9 }, (_v, i) => `com${i + 1}`), + ...Array.from({ length: 9 }, (_v, i) => `lpt${i + 1}`), +]); + +export const LIMITS = Object.freeze({ + maxFiles: 1024, + maxFileBytes: 16 * 1024 * 1024, + maxTotalBytes: 64 * 1024 * 1024, + maxPathBytes: 512, + maxSegmentBytes: 128, + maxPathSegments: 16, +}); + +export const REQUIRED_FILES = [ + '.minimax-plugin/plugin.json', + 'package.json', + 'miniapp/miniapp.json', + 'README.md', + 'LICENSE', +]; + +export const README_HEADINGS = ['## Tested environment', '## Data & access']; + +export const STDOUT_CALL = /\b(?:console\.(?:log|info|debug|dir|table)|process\.stdout\.write)\s*\(/u; +export const START_EXPORT = + /^\s*export\s+(?:async\s+function\s+start\b|function\s+start\b|const\s+start\b|let\s+start\b|\{[^}]*\bstart\b[^}]*\})/mu; diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs new file mode 100644 index 0000000..71e141e --- /dev/null +++ b/scripts/lib/fs.mjs @@ -0,0 +1,44 @@ +import { readFile, stat } from 'node:fs/promises'; + +const BOM = '\uFEFF'; + +export async function readJsonFile(absolutePath) { + let text; + try { + text = await readFile(absolutePath, 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') return { ok: false, reason: 'missing', detail: 'file not found' }; + throw error; + } + if (text.startsWith(BOM)) { + return { ok: false, reason: 'bom', detail: 'starts with a UTF-8 BOM; save the file without a BOM' }; + } + try { + return { ok: true, value: JSON.parse(text) }; + } catch (error) { + return { ok: false, reason: 'parse', detail: `invalid JSON: ${error.message}` }; + } +} + +export async function pathExists(absolutePath, kind) { + try { + const info = await stat(absolutePath); + if (kind === 'file') return info.isFile(); + if (kind === 'directory') return info.isDirectory(); + return true; + } catch { + return false; + } +} + +export async function readText(absolutePath) { + try { + return await readFile(absolutePath, 'utf8'); + } catch { + return undefined; + } +} + +export function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/scripts/lib/paths.mjs b/scripts/lib/paths.mjs new file mode 100644 index 0000000..ccbf4d1 --- /dev/null +++ b/scripts/lib/paths.mjs @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { LIMITS, PORTABLE_SEGMENT, WINDOWS_RESERVED } from './contract.mjs'; + +export function normalizePluginPath(value) { + if (typeof value !== 'string' || !value.trim() || value.includes('\\')) { + return { ok: false, reason: 'must be a plugin-relative path' }; + } + const withoutDot = value.trim().replace(/^\.\//u, ''); + const normalized = path.posix.normalize(withoutDot); + const issue = portablePathIssue(normalized); + if (issue) return { ok: false, reason: issue }; + if (normalized !== withoutDot) return { ok: false, reason: 'must be canonical (no ./, ../, or //)' }; + return { ok: true, value: normalized }; +} + +export function portablePathIssue(relativePath) { + if (!relativePath) return 'path is empty'; + if (!/^[\x00-\x7f]*$/u.test(relativePath)) return 'is not ASCII'; + if (relativePath.startsWith('/') || relativePath.startsWith('\\') || /^[A-Za-z]:[\\/]/u.test(relativePath)) { + return 'is absolute'; + } + if (relativePath.includes('\\')) return 'contains a backslash'; + if (relativePath.endsWith('/')) return 'has a trailing slash'; + if (/[\x00-\x1f\x7f]/u.test(relativePath)) return 'contains a control character'; + if (Buffer.byteLength(relativePath, 'ascii') > LIMITS.maxPathBytes) return 'exceeds the path limit'; + const segments = relativePath.split('/'); + if (segments.length > LIMITS.maxPathSegments) return 'has too many segments'; + for (const segment of segments) { + if (!segment || segment === '.' || segment === '..') return 'has an empty or dot segment'; + if (Buffer.byteLength(segment, 'ascii') > LIMITS.maxSegmentBytes) return `"${segment}" exceeds the segment limit`; + if (!PORTABLE_SEGMENT.test(segment) || segment.endsWith('.')) return `"${segment}" is not portable`; + const [basename = ''] = segment.split('.', 1); + if (WINDOWS_RESERVED.has(basename.toLowerCase())) return `"${segment}" is reserved on Windows`; + } + return undefined; +} + +export function normalizeRoutePath(value) { + if (typeof value !== 'string' || !value.trim()) return { ok: false, reason: 'must be a non-empty route path' }; + const route = value.trim(); + if (route.includes('://') || route.startsWith('//')) return { ok: false, reason: 'must not contain an origin' }; + if (route.includes('\\') || route.includes('?') || route.includes('#')) { + return { ok: false, reason: 'must not contain a backslash, query, or fragment' }; + } + const withSlash = route.startsWith('/') ? route : `/${route}`; + if (path.posix.normalize(withSlash) !== withSlash) return { ok: false, reason: 'must be canonical' }; + return { ok: true, value: withSlash }; +} + +export function isCoveredBy(relativePath, roots) { + return roots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`)); +} diff --git a/scripts/lib/report.mjs b/scripts/lib/report.mjs new file mode 100644 index 0000000..19c1817 --- /dev/null +++ b/scripts/lib/report.mjs @@ -0,0 +1,22 @@ +export function createReport() { + const diagnostics = []; + const push = (level) => (code, message, path) => { + diagnostics.push(path === undefined ? { level, code, message } : { level, code, message, path }); + }; + return { diagnostics, error: push('error'), warning: push('warning') }; +} + +export function hasErrors(diagnostics) { + return diagnostics.some((d) => d.level === 'error'); +} + +export function formatReport(label, diagnostics) { + if (diagnostics.length === 0) return `${label}: OK`; + const errors = diagnostics.filter((d) => d.level === 'error').length; + const warnings = diagnostics.length - errors; + const lines = [`${label}: ${errors} ${errors === 1 ? 'error' : 'errors'}, ${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`]; + for (const d of diagnostics) { + lines.push(` ${d.level} ${d.code} ${d.message}${d.path ? ` (${d.path})` : ''}`); + } + return lines.join('\n'); +} diff --git a/test/paths.test.mjs b/test/paths.test.mjs new file mode 100644 index 0000000..8860501 --- /dev/null +++ b/test/paths.test.mjs @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isCoveredBy, + normalizePluginPath, + normalizeRoutePath, + portablePathIssue, +} from '../scripts/lib/paths.mjs'; + +test('normalizePluginPath strips ./ and rejects non-canonical input', () => { + assert.deepEqual(normalizePluginPath('./miniapp/client'), { ok: true, value: 'miniapp/client' }); + assert.equal(normalizePluginPath('miniapp//client').ok, false); + assert.equal(normalizePluginPath('miniapp\\client').ok, false); + assert.equal(normalizePluginPath('../x').ok, false); + assert.equal(normalizePluginPath('').ok, false); + assert.equal(normalizePluginPath(42).ok, false); +}); + +test('portablePathIssue mirrors the Host rules', () => { + assert.equal(portablePathIssue('miniapp/node/server.mjs'), undefined); + assert.equal(portablePathIssue('.minimax-plugin/plugin.json'), undefined); + assert.match(portablePathIssue('docs/预览.png'), /ASCII/); + assert.match(portablePathIssue('con.txt'), /reserved on Windows/); + assert.match(portablePathIssue('a/b.'), /not portable/); + assert.match(portablePathIssue('a b.txt'), /not portable/); + assert.match(portablePathIssue(`${'x'.repeat(129)}.txt`), /segment limit/); + assert.match(portablePathIssue(Array.from({ length: 17 }, () => 'a').join('/')), /too many segments/); +}); + +test('normalizeRoutePath adds the leading slash and rejects transport syntax', () => { + assert.deepEqual(normalizeRoutePath('dashboard'), { ok: true, value: '/dashboard' }); + assert.deepEqual(normalizeRoutePath('/dashboard'), { ok: true, value: '/dashboard' }); + assert.equal(normalizeRoutePath('https://x/y').ok, false); + assert.equal(normalizeRoutePath('/a?b').ok, false); + assert.equal(normalizeRoutePath('/a#b').ok, false); + assert.equal(normalizeRoutePath('/a/../b').ok, false); + assert.equal(normalizeRoutePath(' ').ok, false); +}); + +test('isCoveredBy matches a root itself and its descendants only', () => { + assert.equal(isCoveredBy('miniapp/node/server.mjs', ['miniapp/node']), true); + assert.equal(isCoveredBy('miniapp/node', ['miniapp/node']), true); + assert.equal(isCoveredBy('miniapp/nodejs/x.mjs', ['miniapp/node']), false); +}); diff --git a/test/report.test.mjs b/test/report.test.mjs new file mode 100644 index 0000000..4af1a4b --- /dev/null +++ b/test/report.test.mjs @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createReport, formatReport, hasErrors } from '../scripts/lib/report.mjs'; + +test('createReport collects levels and hasErrors ignores warnings', () => { + const report = createReport(); + report.warning('W1', 'soft', 'a.txt'); + assert.equal(hasErrors(report.diagnostics), false); + report.error('E1', 'hard'); + assert.equal(hasErrors(report.diagnostics), true); + assert.deepEqual(report.diagnostics, [ + { level: 'warning', code: 'W1', message: 'soft', path: 'a.txt' }, + { level: 'error', code: 'E1', message: 'hard' }, + ]); +}); + +test('formatReport prints one line per diagnostic and a summary', () => { + const report = createReport(); + report.error('E1', 'hard', 'x.json'); + const text = formatReport('plugins/a/b', report.diagnostics); + assert.match(text, /^plugins\/a\/b: 1 error, 0 warnings$/mu); + assert.match(text, /^ error E1 hard \(x\.json\)$/mu); + assert.equal(formatReport('ok', []), 'ok: OK'); +}); From 6cfcd2f80bb3776827be970e61d47e11f4928a82 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 11:56:21 +0800 Subject: [PATCH 02/14] feat: add hello-miniapp example package as the copyable starting point --- .../hello-miniapp/.minimax-plugin/plugin.json | 14 +++ examples/hello-miniapp/LICENSE | 21 ++++ examples/hello-miniapp/README.md | 40 +++++++ examples/hello-miniapp/icon.png | Bin 0 -> 1574 bytes .../hello-miniapp/miniapp/client/index.html | 14 +++ examples/hello-miniapp/miniapp/miniapp.json | 16 +++ .../hello-miniapp/miniapp/node/miniapp-api.ts | 109 ++++++++++++++++++ .../hello-miniapp/miniapp/node/server.mjs | 62 ++++++++++ examples/hello-miniapp/package.json | 6 + 9 files changed, 282 insertions(+) create mode 100644 examples/hello-miniapp/.minimax-plugin/plugin.json create mode 100644 examples/hello-miniapp/LICENSE create mode 100644 examples/hello-miniapp/README.md create mode 100644 examples/hello-miniapp/icon.png create mode 100644 examples/hello-miniapp/miniapp/client/index.html create mode 100644 examples/hello-miniapp/miniapp/miniapp.json create mode 100644 examples/hello-miniapp/miniapp/node/miniapp-api.ts create mode 100644 examples/hello-miniapp/miniapp/node/server.mjs create mode 100644 examples/hello-miniapp/package.json diff --git a/examples/hello-miniapp/.minimax-plugin/plugin.json b/examples/hello-miniapp/.minimax-plugin/plugin.json new file mode 100644 index 0000000..c3a23e1 --- /dev/null +++ b/examples/hello-miniapp/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "hello-miniapp", + "displayName": "Hello Mini App", + "version": "1.0.0", + "description": "The smallest Mini App package: a Node entry that serves one static page. Copy it to start your own.", + "author": "MiniMax", + "icon": "icon.png", + "category": "Other", + "exampleQueries": ["Open Hello Mini App"], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/examples/hello-miniapp/LICENSE b/examples/hello-miniapp/LICENSE new file mode 100644 index 0000000..132dd20 --- /dev/null +++ b/examples/hello-miniapp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/hello-miniapp/README.md b/examples/hello-miniapp/README.md new file mode 100644 index 0000000..67916b3 --- /dev/null +++ b/examples/hello-miniapp/README.md @@ -0,0 +1,40 @@ +# Hello Mini App (`hello-miniapp`) + +The smallest package that MiniMax Code accepts as a Mini App: one Node entry that serves one static +page. Copy this directory to `plugins///` and replace `name`, +`displayName`, `description`, `author`, `exampleQueries`, the page, this README, and the LICENSE +holder. + +## Install + +Copy this directory, including the hidden `.minimax-plugin/`, to `/plugins/hello-miniapp/` +(by default `~/.minimax/plugins/hello-miniapp/`). Restart MiniMax Code and ask the Agent to +"Open Hello Mini App". + +## Tested environment + +- MiniMax Code 3.0.73 on macOS. Installed from this directory, opened through the Agent, page + rendered. + +## Data & access + +- Files: reads only its own `miniapp/client/index.html`. Writes nothing. +- Network: none. +- Processes: none. +- State: none stored. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest +package.json Mini App declaration +miniapp/miniapp.json Payload roots, Node entry, page route +miniapp/client/index.html The page served at /dashboard +miniapp/node/server.mjs Node entry: start(context) → { dispose } +miniapp/node/miniapp-api.ts Type declarations for the runtime context +icon.png Plugin icon +``` + +## License + +[MIT](./LICENSE) diff --git a/examples/hello-miniapp/icon.png b/examples/hello-miniapp/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..299a26cbf5c1427dffa9cd575cf721307d570765 GIT binary patch literal 1574 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7=|>;uumf=gr;N9Fb5F_7BDH zN*t40jx3m@A#jD`h{82~A;W#9hIKB=6V;QM-fp_!Iw3Du_^7z>7spdwD|5UT=(0U- z4vcs42{>&0A!&-l#UFng7Vv9zUfN`Qd~;|s@7+uz9O)JMQ;7Q z+VlNc*1f)P;(=w;neIK@5%Dq?bm#T^{r{9*@ia!gnj^~T2}4Q1*Th_Vu`>}GpIDCO z#4fN@Vt4rd)BClU(Q~fW#@QT3`o97jTK?xMOo;EM)AkEGZ>^la!>4}Tg|#orV!ID- z4G8cmi29Kul*I@Nov(+hUWIHbj*XAkN|#&6e?ev6^ZSxvcK?~yFA_-*H<;a0#(2g^ zzl72KdH~l2CW&?ibaY*)m{GcGsq4HCVQW8`+3MMAA9>%$$6x1gcEXjIh6=|UTHH*2 zPm^w)+0I})`*&FN5&Nu1Y+r6yc*L8(Sa9m$yrfKZ#~fa1&NHPdC8Y+RdyG?WF};29 zt~O0_E62oyX%mAzpJu)CiI9%EV`HT-k0aLM2a{@p^XrK&uP*%0esO=1w)UU=+zCGMvNmy= z|6P>ouW5@p2v)su`cxCfV?$ymt*7pfdX*%ncOdCuPzej4`!;F|NW(R z>9S1?D}6X`#NYh7GVkf}bgjz@`%TThovQS5e3ma}8>P8_foSNz|MS*u5I(ouy{)ZH zDAVlt_qYF?TCQcTsM{ODK6ko9)iJH7$x{Rh??u)A%=k6?)3WE!yQWM!akgaN_W!DX z-#&jmHN3X`^}*WR`-q(SpO3zY`t01fRdCqjHPcc83_B1GbPU2zNIsfpf)(T$7XRi5-O#h75#2n`^6HJMF!qC&lGw+h(&G*h? zk*k_rMVov+YqaeSTOYYZzjSt0F6+COd#ca&eqZ;_rtH&`lb%gyI!#lfHFBb5N}kls z$lZDCNY^>py$dH>z2A9YX;J*2GNpMejVH=I&vYJ{xB2|{X>r$mYq=dibJoiERAfK) zQ9W)K_T5@<--_8kB%7Wkzk0OuD!*pi4Mquhsb5FS-xaZWR zZ|9tq^y-Rf{wEFBD;u8QSW~|%f9dnxxlsofN1ZNZ>HPit!>su-YkBth3z|N4bh|s- zYxS08(KkMRy%ipDKxc+#k7En7+RrklI~Sdmo1bg6`PiN0n#CTy2vIV@izrM+Ju?Ht m|NpbhtN4MXJsYT)XJ&Z(ZtAoneFio_5e83JKbLh*2~7YK=FPVN literal 0 HcmV?d00001 diff --git a/examples/hello-miniapp/miniapp/client/index.html b/examples/hello-miniapp/miniapp/client/index.html new file mode 100644 index 0000000..f62724a --- /dev/null +++ b/examples/hello-miniapp/miniapp/client/index.html @@ -0,0 +1,14 @@ + + + + + + Hello Mini App + + +
+

Hello Mini App

+

This page is served by miniapp/node/server.mjs. Replace it with your own Client.

+
+ + diff --git a/examples/hello-miniapp/miniapp/miniapp.json b/examples/hello-miniapp/miniapp/miniapp.json new file mode 100644 index 0000000..d898d74 --- /dev/null +++ b/examples/hello-miniapp/miniapp/miniapp.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/examples/hello-miniapp/miniapp/node/miniapp-api.ts b/examples/hello-miniapp/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/examples/hello-miniapp/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/examples/hello-miniapp/miniapp/node/server.mjs b/examples/hello-miniapp/miniapp/node/server.mjs new file mode 100644 index 0000000..48b3362 --- /dev/null +++ b/examples/hello-miniapp/miniapp/node/server.mjs @@ -0,0 +1,62 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +/** + * Serve the routes declared in miniapp.json. Replace `/dashboard` with your surface path. + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const clientEntry = await readFile(join(context.pluginRoot, 'miniapp/client/index.html')); + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + if (request.method === 'GET' && url.pathname === '/dashboard') { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(clientEntry); + return; + } + response.writeHead(404, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'not_found' })); + }); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/examples/hello-miniapp/package.json b/examples/hello-miniapp/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/examples/hello-miniapp/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} From e2445b20df98efca1b1da0e11235369c4c37d7cf Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 12:05:02 +0800 Subject: [PATCH 03/14] feat(check): validate package layout and plugin.json against the host manifest rules --- scripts/lib/rules/layout.mjs | 54 +++++++++++++ scripts/lib/rules/manifest.mjs | 142 +++++++++++++++++++++++++++++++++ test/helpers.mjs | 35 ++++++++ test/layout.test.mjs | 51 ++++++++++++ test/manifest.test.mjs | 88 ++++++++++++++++++++ 5 files changed, 370 insertions(+) create mode 100644 scripts/lib/rules/layout.mjs create mode 100644 scripts/lib/rules/manifest.mjs create mode 100644 test/helpers.mjs create mode 100644 test/layout.test.mjs create mode 100644 test/manifest.test.mjs diff --git a/scripts/lib/rules/layout.mjs b/scripts/lib/rules/layout.mjs new file mode 100644 index 0000000..642b52f --- /dev/null +++ b/scripts/lib/rules/layout.mjs @@ -0,0 +1,54 @@ +import { open } from 'node:fs/promises'; +import path from 'node:path'; + +import { REQUIRED_FILES } from '../contract.mjs'; +import { pathExists } from '../fs.mjs'; + +export async function checkLayout(report, { packageDir, requireLowercaseAuthor }) { + if (requireLowercaseAuthor) { + const author = path.basename(path.dirname(packageDir)); + if (author !== author.toLowerCase()) { + report.error('LAYOUT_AUTHOR_NOT_LOWERCASE', `author directory "${author}" must be lowercase`); + } + } + for (const relativePath of REQUIRED_FILES) { + if (!(await pathExists(path.join(packageDir, ...relativePath.split('/')), 'file'))) { + report.error('LAYOUT_FILE_MISSING', 'required file is missing', relativePath); + } + } +} + +export async function checkNameMatchesDirectory(report, { packageDir, name }) { + const dirName = path.basename(packageDir); + if (name !== undefined && name !== dirName) { + report.error( + 'LAYOUT_NAME_MISMATCH', + `directory is "${dirName}" but plugin.json name is "${name}"; they must be identical`, + '.minimax-plugin/plugin.json', + ); + } +} + +const SIGNATURES = [ + { kind: 'PNG', test: (b) => b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }, + { kind: 'JPEG', test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, + { kind: 'WebP', test: (b) => b.length >= 12 && b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP' }, +]; + +export async function checkImageFile(report, { packageDir, relativePath, label }) { + const absolute = path.join(packageDir, ...relativePath.split('/')); + if (!(await pathExists(absolute, 'file'))) { + report.error('LAYOUT_IMAGE_INVALID', `${label} file does not exist`, relativePath); + return; + } + const handle = await open(absolute, 'r'); + try { + const { buffer, bytesRead } = await handle.read(Buffer.alloc(12), 0, 12, 0); + const head = buffer.subarray(0, bytesRead); + if (!SIGNATURES.some((s) => s.test(head))) { + report.error('LAYOUT_IMAGE_INVALID', `${label} must be a PNG, JPEG, or WebP image`, relativePath); + } + } finally { + await handle.close(); + } +} diff --git a/scripts/lib/rules/manifest.mjs b/scripts/lib/rules/manifest.mjs new file mode 100644 index 0000000..247fd9f --- /dev/null +++ b/scripts/lib/rules/manifest.mjs @@ -0,0 +1,142 @@ +import path from 'node:path'; + +import { + APP_PATH, + CATEGORIES, + HOOK_PATH, + HOST_BINDING_PATH, + ICON_PATH, + MANIFEST_FIELDS, + MCP_PATH, + PLUGIN_NAME, + PLUGIN_NAME_MAX_LENGTH, + REFERENCE_MAX_LENGTH, + SEMVER, + SKILL_PATH, + VERSION_MAX_LENGTH, +} from '../contract.mjs'; +import { isRecord, pathExists } from '../fs.mjs'; + +const FILE = '.minimax-plugin/plugin.json'; + +export async function checkManifest(report, { packageDir, manifest }) { + if (!isRecord(manifest)) { + report.error('MANIFEST_NOT_OBJECT', 'plugin.json must be a JSON object', FILE); + return {}; + } + for (const key of Object.keys(manifest)) { + if (!MANIFEST_FIELDS.has(key)) report.error('MANIFEST_UNKNOWN_FIELD', `unknown field "${key}"`, FILE); + } + if (manifest.$schema !== undefined && typeof manifest.$schema !== 'string') { + report.error('MANIFEST_FIELD_INVALID', '$schema must be a string', FILE); + } + if (manifest.schemaVersion !== 1) report.error('MANIFEST_SCHEMA_VERSION', 'schemaVersion must be 1', FILE); + + const name = requiredString(report, manifest, 'name'); + if (name !== undefined && (name.length > PLUGIN_NAME_MAX_LENGTH || !PLUGIN_NAME.test(name))) { + report.error('MANIFEST_FIELD_INVALID', `name "${name}" must match ${PLUGIN_NAME.source} and be at most ${PLUGIN_NAME_MAX_LENGTH} characters`, FILE); + } + const version = requiredString(report, manifest, 'version'); + if (version !== undefined && (version.length > VERSION_MAX_LENGTH || !SEMVER.test(version))) { + report.error('MANIFEST_FIELD_INVALID', `version "${version}" must be SemVer`, FILE); + } + optionalString(report, manifest, 'displayName'); + requiredString(report, manifest, 'description'); + requiredString(report, manifest, 'author'); + + const icon = imagePath(report, manifest.icon, 'icon', true); + const darkIcon = imagePath(report, manifest.darkIcon, 'darkIcon', false); + + if (typeof manifest.category !== 'string' || !CATEGORIES.includes(manifest.category)) { + report.error('MANIFEST_FIELD_INVALID', `category must be one of: ${CATEGORIES.join(', ')}`, FILE); + } + + const queries = stringArray(report, manifest.exampleQueries, 'exampleQueries'); + if (queries) { + if (queries.some((q) => !q.trim())) report.error('MANIFEST_FIELD_INVALID', 'exampleQueries entries must not be blank', FILE); + else if (queries.length === 0) { + report.warning('MANIFEST_EXAMPLE_QUERIES_EMPTY', 'exampleQueries is empty; add at least one so the Agent can open the Mini App by name', FILE); + } + } + + const apps = await references(report, packageDir, manifest.apps, 'apps', APP_PATH, { required: true, mustExist: false }); + if (apps && apps.length > 0) { + report.warning('MANIFEST_APPS_IGNORED', 'apps entries are ignored for locally installed packages', FILE); + } + await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true }); + await references(report, packageDir, manifest.skills, 'skills', SKILL_PATH, { required: true, mustExist: true }); + await references(report, packageDir, manifest.hooks, 'hooks', HOOK_PATH, { required: false, mustExist: true }); + await references(report, packageDir, manifest.hostBindings, 'hostBindings', HOST_BINDING_PATH, { required: false, mustExist: true }); + + const identity = {}; + if (name !== undefined) identity.name = name; + if (icon !== undefined) identity.icon = icon; + if (darkIcon !== undefined) identity.darkIcon = darkIcon; + return identity; +} + +function optionalString(report, manifest, key) { + const value = manifest[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || !value.trim()) { + report.error('MANIFEST_FIELD_INVALID', `${key} must be a non-empty string`, FILE); + return undefined; + } + return value.trim(); +} + +function requiredString(report, manifest, key) { + if (manifest[key] === undefined) { + report.error('MANIFEST_FIELD_INVALID', `${key} is required`, FILE); + return undefined; + } + return optionalString(report, manifest, key); +} + +function imagePath(report, value, label, required) { + if (value === undefined) { + if (required) report.error('MANIFEST_FIELD_INVALID', `${label} is required`, FILE); + return undefined; + } + if (typeof value !== 'string' || !value.trim()) { + report.error('MANIFEST_FIELD_INVALID', `${label} must be a non-empty string`, FILE); + return undefined; + } + const trimmed = value.trim(); + if (trimmed.length > REFERENCE_MAX_LENGTH || !ICON_PATH.test(trimmed)) { + report.error('MANIFEST_FIELD_INVALID', `${label} must be a plugin-relative .png, .jpg, .jpeg, or .webp path`, FILE); + return undefined; + } + return trimmed; +} + +function stringArray(report, value, label) { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + report.error('MANIFEST_FIELD_INVALID', `${label} must be a string array`, FILE); + return undefined; + } + return value; +} + +async function references(report, packageDir, value, label, pattern, { required, mustExist }) { + if (value === undefined) { + if (required) report.error('MANIFEST_FIELD_INVALID', `${label} is required (use [] when empty)`, FILE); + return undefined; + } + const items = stringArray(report, value, label); + if (!items) return undefined; + if (new Set(items).size !== items.length) { + report.error('MANIFEST_REFERENCE_INVALID', `${label} contains a duplicate`, FILE); + return items; + } + for (const item of items) { + if (item.length > REFERENCE_MAX_LENGTH || !pattern.test(item)) { + report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must match ${pattern.source}`, FILE); + continue; + } + if (mustExist && !(await pathExists(path.join(packageDir, ...item.split('/')), 'file'))) { + report.error('MANIFEST_REFERENCE_MISSING', `${label} entry "${item}" does not exist`, item); + } + } + return items; +} diff --git a/test/helpers.mjs b/test/helpers.mjs new file mode 100644 index 0000000..a2ce9ba --- /dev/null +++ b/test/helpers.mjs @@ -0,0 +1,35 @@ +import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const EXAMPLE_DIR = path.join(REPO_ROOT, 'examples', 'hello-miniapp'); + +export async function makeTmpRoot() { + return mkdtemp(path.join(tmpdir(), 'miniapps-check-')); +} + +export async function copyExample(root, { author = 'tester', dirName = 'hello-miniapp' } = {}) { + const dir = path.join(root, 'plugins', author, dirName); + await cp(EXAMPLE_DIR, dir, { recursive: true }); + return { dir }; +} + +export async function readJson(file) { + return JSON.parse(await readFile(file, 'utf8')); +} + +export async function writeJson(file, value) { + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function editJson(file, mutate) { + const value = await readJson(file); + const next = mutate(value) ?? value; + await writeJson(file, next); +} + +export function codes(diagnostics, level) { + return diagnostics.filter((d) => !level || d.level === level).map((d) => d.code); +} diff --git a/test/layout.test.mjs b/test/layout.test.mjs new file mode 100644 index 0000000..a4720be --- /dev/null +++ b/test/layout.test.mjs @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { checkImageFile, checkLayout, checkNameMatchesDirectory } from '../scripts/lib/rules/layout.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot } from './helpers.mjs'; + +test('layout: baseline copy passes', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const report = createReport(); + await checkLayout(report, { packageDir: dir, requireLowercaseAuthor: true }); + await checkNameMatchesDirectory(report, { packageDir: dir, name: 'hello-miniapp' }); + await checkImageFile(report, { packageDir: dir, relativePath: 'icon.png', label: 'icon' }); + assert.deepEqual(report.diagnostics, []); +}); + +test('layout: missing LICENSE and uppercase author are errors', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root, { author: 'Tester' }); + await rm(path.join(dir, 'LICENSE')); + const report = createReport(); + await checkLayout(report, { packageDir: dir, requireLowercaseAuthor: true }); + assert.deepEqual(codes(report.diagnostics), ['LAYOUT_AUTHOR_NOT_LOWERCASE', 'LAYOUT_FILE_MISSING']); +}); + +test('layout: author case is not checked for examples', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root, { author: 'Tester' }); + const report = createReport(); + await checkLayout(report, { packageDir: dir, requireLowercaseAuthor: false }); + assert.deepEqual(report.diagnostics, []); +}); + +test('layout: directory name must equal manifest name', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root, { dirName: 'other-name' }); + const report = createReport(); + await checkNameMatchesDirectory(report, { packageDir: dir, name: 'hello-miniapp' }); + assert.deepEqual(codes(report.diagnostics), ['LAYOUT_NAME_MISMATCH']); +}); + +test('layout: icon must be a real PNG, JPEG, or WebP', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await writeFile(path.join(dir, 'icon.png'), 'not an image'); + const report = createReport(); + await checkImageFile(report, { packageDir: dir, relativePath: 'icon.png', label: 'icon' }); + assert.deepEqual(codes(report.diagnostics), ['LAYOUT_IMAGE_INVALID']); +}); diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs new file mode 100644 index 0000000..f517eeb --- /dev/null +++ b/test/manifest.test.mjs @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { checkManifest } from '../scripts/lib/rules/manifest.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot, readJson } from './helpers.mjs'; + +async function run(mutate, prepare) { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const manifest = await readJson(path.join(dir, '.minimax-plugin', 'plugin.json')); + const result = mutate(manifest); + const next = result === undefined ? manifest : result; + if (prepare) await prepare(dir); + const report = createReport(); + const identity = await checkManifest(report, { packageDir: dir, manifest: next }); + return { report, identity }; +} + +test('manifest: baseline passes and returns identity', async () => { + const { report, identity } = await run((m) => m); + assert.deepEqual(report.diagnostics, []); + assert.deepEqual(identity, { name: 'hello-miniapp', icon: 'icon.png' }); +}); + +test('manifest: $schema is an accepted optional string', async () => { + const { report } = await run((m) => ({ $schema: 'https://example.com/plugin.schema.json', ...m })); + assert.deepEqual(report.diagnostics, []); +}); + +test('manifest: unknown field, wrong schemaVersion, bad name, bad version', async () => { + const { report } = await run((m) => ({ ...m, schemaVersion: 2, name: 'Bad Name', version: '1.0', extra: true })); + assert.deepEqual(codes(report.diagnostics), [ + 'MANIFEST_UNKNOWN_FIELD', + 'MANIFEST_SCHEMA_VERSION', + 'MANIFEST_FIELD_INVALID', + 'MANIFEST_FIELD_INVALID', + ]); +}); + +test('manifest: category outside the list is an error', async () => { + const { report } = await run((m) => ({ ...m, category: 'Utilities' })); + assert.deepEqual(codes(report.diagnostics), ['MANIFEST_FIELD_INVALID']); +}); + +test('manifest: empty exampleQueries is a warning; blank entry is an error', async () => { + const empty = await run((m) => ({ ...m, exampleQueries: [] })); + assert.deepEqual(codes(empty.report.diagnostics), ['MANIFEST_EXAMPLE_QUERIES_EMPTY']); + assert.equal(empty.report.diagnostics[0].level, 'warning'); + const blank = await run((m) => ({ ...m, exampleQueries: ['ok', ' '] })); + assert.deepEqual(codes(blank.report.diagnostics), ['MANIFEST_FIELD_INVALID']); +}); + +test('manifest: apps entries must match *.app.json; non-empty apps only warns', async () => { + const bad = await run((m) => ({ ...m, apps: ['foo'] })); + assert.deepEqual(codes(bad.report.diagnostics, 'error'), ['MANIFEST_REFERENCE_INVALID']); + const ok = await run((m) => ({ ...m, apps: ['x.app.json'] })); + assert.deepEqual(codes(ok.report.diagnostics), ['MANIFEST_APPS_IGNORED']); + assert.equal(ok.report.diagnostics[0].level, 'warning'); +}); + +test('manifest: skills, mcpServers, hooks, hostBindings need matching pattern and existing file', async () => { + const missing = await run((m) => ({ ...m, skills: ['skills/demo/SKILL.md'], hostBindings: ['bindings/x.binding.json'] })); + assert.deepEqual(codes(missing.report.diagnostics), ['MANIFEST_REFERENCE_MISSING', 'MANIFEST_REFERENCE_MISSING']); + const present = await run( + (m) => ({ ...m, skills: ['skills/demo/SKILL.md'], mcpServers: ['tools.mcp.json'], hooks: ['hooks/a.json'], hostBindings: ['bindings/x.binding.json'] }), + async (dir) => { + await mkdir(path.join(dir, 'skills', 'demo'), { recursive: true }); + await writeFile(path.join(dir, 'skills', 'demo', 'SKILL.md'), '# demo\n'); + await writeFile(path.join(dir, 'tools.mcp.json'), '{}\n'); + await mkdir(path.join(dir, 'hooks'), { recursive: true }); + await writeFile(path.join(dir, 'hooks', 'a.json'), '{}\n'); + await mkdir(path.join(dir, 'bindings'), { recursive: true }); + await writeFile(path.join(dir, 'bindings', 'x.binding.json'), '{}\n'); + }, + ); + assert.deepEqual(present.report.diagnostics, []); + const badPattern = await run((m) => ({ ...m, skills: ['demo/SKILL.md'] })); + assert.deepEqual(codes(badPattern.report.diagnostics), ['MANIFEST_REFERENCE_INVALID']); + const duplicate = await run((m) => ({ ...m, hooks: ['hooks/a.json', 'hooks/a.json'] })); + assert.deepEqual(codes(duplicate.report.diagnostics), ['MANIFEST_REFERENCE_INVALID']); +}); + +test('manifest: non-object is a single error', async () => { + const { report } = await run(() => null); + assert.deepEqual(codes(report.diagnostics), ['MANIFEST_NOT_OBJECT']); +}); From e8e0d5cfb93c5ebe478924ecf4ee85437b1f49c3 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 12:22:49 +0800 Subject: [PATCH 04/14] feat(check): validate the mcode declaration and miniapp.json payload, runtime, and routes --- scripts/lib/rules/mcode.mjs | 14 +++ scripts/lib/rules/miniapp.mjs | 161 ++++++++++++++++++++++++++++++++++ test/mcode.test.mjs | 32 +++++++ test/miniapp.test.mjs | 88 +++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 scripts/lib/rules/mcode.mjs create mode 100644 scripts/lib/rules/miniapp.mjs create mode 100644 test/mcode.test.mjs create mode 100644 test/miniapp.test.mjs diff --git a/scripts/lib/rules/mcode.mjs b/scripts/lib/rules/mcode.mjs new file mode 100644 index 0000000..df3e507 --- /dev/null +++ b/scripts/lib/rules/mcode.mjs @@ -0,0 +1,14 @@ +import { MCODE_EXPECTED } from '../contract.mjs'; +import { isRecord } from '../fs.mjs'; + +const MESSAGE = 'package.json#mcode must be exactly { "schemaVersion": 2, "miniApp": "./miniapp/miniapp.json" }'; + +export function checkMcode(report, { packageJson }) { + const mcode = isRecord(packageJson) ? packageJson.mcode : undefined; + const expectedKeys = Object.keys(MCODE_EXPECTED); + const ok = + isRecord(mcode) && + Object.keys(mcode).length === expectedKeys.length && + expectedKeys.every((key) => mcode[key] === MCODE_EXPECTED[key]); + if (!ok) report.error('MCODE_INVALID', MESSAGE, 'package.json'); +} diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs new file mode 100644 index 0000000..6ad94f9 --- /dev/null +++ b/scripts/lib/rules/miniapp.mjs @@ -0,0 +1,161 @@ +import path from 'node:path'; + +import { + ARTIFACT_FIELDS, + CONNECTOR_PROVIDER, + ENTRY_EXTENSION, + EXCLUDED_DIRECTORY, + HOST_CONNECTOR_ACCESS_FIELDS, + MCP_ENDPOINT_FIELDS, + MCP_SERVER_NAME, + MINIAPP_FIELDS, + PAYLOAD_DIRECTORY, + RUNTIME_FIELDS, + SURFACE_FIELDS, +} from '../contract.mjs'; +import { isRecord, pathExists } from '../fs.mjs'; +import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.mjs'; + +const FILE = 'miniapp/miniapp.json'; + +export async function checkMiniApp(report, { packageDir, manifest }) { + const resolved = { nodeRoots: [], clientRoots: [] }; + if (!isRecord(manifest)) { + report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE); + return resolved; + } + for (const key of Object.keys(manifest)) { + if (!MINIAPP_FIELDS.has(key)) report.error('MINIAPP_UNKNOWN_FIELD', `unknown field "${key}"`, FILE); + } + if (manifest.schemaVersion !== 1) report.error('MINIAPP_SCHEMA_VERSION', 'schemaVersion must be 1', FILE); + + const artifacts = await checkArtifacts(report, packageDir, manifest.artifacts); + resolved.clientRoots = artifacts.client; + resolved.nodeRoots = artifacts.node; + const entry = await checkRuntime(report, packageDir, manifest.runtime, artifacts.node); + if (entry) resolved.entry = entry; + checkSurface(report, manifest.surface); + checkMcpEndpoints(report, manifest.mcpEndpoints); + checkHostConnectorAccess(report, manifest.hostConnectorAccess); + return resolved; +} + +async function checkArtifacts(report, packageDir, value) { + const roots = { client: [], node: [] }; + if (!isRecord(value) || Object.keys(value).some((k) => !ARTIFACT_FIELDS.has(k))) { + report.error('MINIAPP_ARTIFACTS_INVALID', 'artifacts must be an object with only "client" and "node"', FILE); + return roots; + } + for (const key of ARTIFACT_FIELDS) { + const list = value[key]; + if (!Array.isArray(list) || list.length === 0 || list.some((item) => typeof item !== 'string')) { + report.error('MINIAPP_ARTIFACTS_INVALID', `artifacts.${key} must be a non-empty string array`, FILE); + continue; + } + const normalized = []; + for (const item of list) { + const result = normalizePluginPath(item); + if (!result.ok) { + report.error('MINIAPP_ARTIFACTS_INVALID', `artifacts.${key} entry "${item}" ${result.reason}`, FILE); + continue; + } + if (!result.value.startsWith(`${PAYLOAD_DIRECTORY}/`)) { + report.error('MINIAPP_ARTIFACTS_INVALID', `artifacts.${key} entry "${item}" must stay under ${PAYLOAD_DIRECTORY}/`, FILE); + continue; + } + if (result.value.split('/').some((segment) => segment.toLowerCase() === EXCLUDED_DIRECTORY)) { + report.error('MINIAPP_ARTIFACTS_INVALID', `artifacts.${key} entry "${item}" is excluded from runtime payloads`, FILE); + continue; + } + if (!(await pathExists(path.join(packageDir, ...result.value.split('/'))))) { + report.error('MINIAPP_ARTIFACT_MISSING', `artifacts.${key} entry "${item}" is not on disk (git does not keep empty directories; add at least one file)`, FILE); + continue; + } + normalized.push(result.value); + } + if (new Set(normalized).size !== normalized.length) { + report.error('MINIAPP_ARTIFACTS_INVALID', `artifacts.${key} contains a duplicate`, FILE); + } + roots[key] = [...new Set(normalized)]; + } + return roots; +} + +async function checkRuntime(report, packageDir, value, nodeRoots) { + if (!isRecord(value) || value.kind !== 'process') { + report.error('MINIAPP_RUNTIME_INVALID', 'runtime must be an object with kind "process"', FILE); + return undefined; + } + for (const key of Object.keys(value)) { + if (!RUNTIME_FIELDS.has(key)) report.error('MINIAPP_RUNTIME_INVALID', `runtime has unknown field "${key}"`, FILE); + } + if (value.lifecycle !== undefined && value.lifecycle !== 'on-demand') { + report.error('MINIAPP_RUNTIME_INVALID', 'runtime.lifecycle must be "on-demand" or omitted', FILE); + } + const entry = normalizePluginPath(value.entry); + if (!entry.ok) { + report.error('MINIAPP_RUNTIME_INVALID', `runtime.entry ${entry.reason}`, FILE); + return undefined; + } + if (!ENTRY_EXTENSION.test(entry.value)) { + report.error('MINIAPP_RUNTIME_INVALID', 'runtime.entry must end in .js, .mjs, or .cjs', FILE); + return undefined; + } + if (!(await pathExists(path.join(packageDir, ...entry.value.split('/')), 'file'))) { + report.error('MINIAPP_ENTRY_MISSING', `runtime.entry "${entry.value}" does not exist`, FILE); + return undefined; + } + if (!isCoveredBy(entry.value, nodeRoots)) { + report.error('MINIAPP_ENTRY_NOT_COVERED', `runtime.entry "${entry.value}" must be inside one of artifacts.node`, FILE); + } + return entry.value; +} + +function checkSurface(report, value) { + if (!isRecord(value) || Object.keys(value).some((k) => !SURFACE_FIELDS.has(k))) { + report.error('MINIAPP_SURFACE_INVALID', 'surface must be an object with only "path"', FILE); + return; + } + const route = normalizeRoutePath(value.path); + if (!route.ok) report.error('MINIAPP_SURFACE_INVALID', `surface.path ${route.reason}`, FILE); +} + +function checkMcpEndpoints(report, value) { + if (value === undefined) return; + if (!Array.isArray(value)) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE); + return; + } + const servers = new Set(); + const paths = new Set(); + for (const item of value) { + if (!isRecord(item) || Object.keys(item).some((k) => !MCP_ENDPOINT_FIELDS.has(k))) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'each MCP endpoint must be an object with only "server" and "path"', FILE); + continue; + } + if (typeof item.server !== 'string' || !MCP_SERVER_NAME.test(item.server)) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint server must match ${MCP_SERVER_NAME.source}`, FILE); + continue; + } + const route = normalizeRoutePath(item.path); + if (!route.ok) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint path ${route.reason}`, FILE); + continue; + } + if (servers.has(item.server) || paths.has(route.value)) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'MCP endpoint server and path must each be unique', FILE); + } + servers.add(item.server); + paths.add(route.value); + } +} + +function checkHostConnectorAccess(report, value) { + if (value === undefined) return; + const providers = isRecord(value) && !Object.keys(value).some((k) => !HOST_CONNECTOR_ACCESS_FIELDS.has(k)) ? value.providers : undefined; + if (!Array.isArray(providers) || providers.some((p) => typeof p !== 'string' || !CONNECTOR_PROVIDER.test(p)) || new Set(providers).size !== providers.length) { + report.error('MINIAPP_HOST_CONNECTOR_INVALID', `hostConnectorAccess.providers must be unique strings matching ${CONNECTOR_PROVIDER.source}`, FILE); + return; + } + report.warning('HOST_CONNECTOR_UNVERIFIED', 'declared providers are granted by the Host at install time and are not checked here', FILE); +} diff --git a/test/mcode.test.mjs b/test/mcode.test.mjs new file mode 100644 index 0000000..37a335c --- /dev/null +++ b/test/mcode.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { checkMcode } from '../scripts/lib/rules/mcode.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes } from './helpers.mjs'; + +const run = (packageJson) => { + const report = createReport(); + checkMcode(report, { packageJson }); + return report.diagnostics; +}; + +test('mcode: exact declaration passes, extra top-level keys are fine', () => { + assert.deepEqual(run({ mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json' } }), []); + assert.deepEqual(run({ name: 'x', type: 'module', mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json' } }), []); +}); + +test('mcode: any deviation is the same single error', () => { + const expectedMessage = 'package.json#mcode must be exactly { "schemaVersion": 2, "miniApp": "./miniapp/miniapp.json" }'; + for (const packageJson of [ + {}, + { mcode: { schemaVersion: 1, miniApp: './miniapp/miniapp.json' } }, + { mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json', extra: 1 } }, + { mcode: { schemaVersion: 2, miniApp: 'miniapp/miniapp.json' } }, + { mcode: 'nope' }, + null, + ]) { + const diagnostics = run(packageJson); + assert.deepEqual(codes(diagnostics), ['MCODE_INVALID']); + assert.equal(diagnostics[0].message, expectedMessage); + } +}); diff --git a/test/miniapp.test.mjs b/test/miniapp.test.mjs new file mode 100644 index 0000000..86ce27f --- /dev/null +++ b/test/miniapp.test.mjs @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; +import { checkMiniApp } from '../scripts/lib/rules/miniapp.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot, readJson } from './helpers.mjs'; + +async function run(mutate, prepare) { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const manifest = await readJson(path.join(dir, 'miniapp', 'miniapp.json')); + const result = mutate(manifest); + const next = result === undefined ? manifest : result; + if (prepare) await prepare(dir); + const report = createReport(); + const resolved = await checkMiniApp(report, { packageDir: dir, manifest: next }); + return { report, resolved }; +} + +test('miniapp: baseline passes and resolves entry and roots', async () => { + const { report, resolved } = await run((m) => m); + assert.deepEqual(report.diagnostics, []); + assert.deepEqual(resolved, { entry: 'miniapp/node/server.mjs', nodeRoots: ['miniapp/node'], clientRoots: ['miniapp/client'] }); +}); + +test('miniapp: lifecycle may be omitted; other values are errors', async () => { + const omitted = await run((m) => { delete m.runtime.lifecycle; return m; }); + assert.deepEqual(omitted.report.diagnostics, []); + const other = await run((m) => { m.runtime.lifecycle = 'always'; return m; }); + assert.deepEqual(codes(other.report.diagnostics), ['MINIAPP_RUNTIME_INVALID']); +}); + +test('miniapp: entry must end in .js/.mjs/.cjs, exist, and sit under a node root', async () => { + const ts = await run((m) => { m.runtime.entry = './miniapp/node/server.ts'; return m; }); + assert.deepEqual(codes(ts.report.diagnostics), ['MINIAPP_RUNTIME_INVALID']); + const missing = await run((m) => { m.runtime.entry = './miniapp/node/other.mjs'; return m; }); + assert.deepEqual(codes(missing.report.diagnostics), ['MINIAPP_ENTRY_MISSING']); + const outside = await run((m) => { m.artifacts.node = ['./miniapp/server']; return m; }, async (dir) => { + const { mkdir, writeFile } = await import('node:fs/promises'); + await mkdir(path.join(dir, 'miniapp', 'server')); + await writeFile(path.join(dir, 'miniapp', 'server', 'keep.txt'), ''); + }); + assert.deepEqual(codes(outside.report.diagnostics), ['MINIAPP_ENTRY_NOT_COVERED']); +}); + +test('miniapp: an artifact root that is not on disk is an error (git drops empty directories)', async () => { + const { report } = await run((m) => m, async (dir) => rm(path.join(dir, 'miniapp', 'client'), { recursive: true })); + assert.deepEqual(codes(report.diagnostics), ['MINIAPP_ARTIFACT_MISSING']); +}); + +test('miniapp: artifact roots must be non-empty, unique, and under miniapp/', async () => { + const empty = await run((m) => { m.artifacts.client = []; return m; }); + assert.deepEqual(codes(empty.report.diagnostics), ['MINIAPP_ARTIFACTS_INVALID']); + const dup = await run((m) => { m.artifacts.client = ['./miniapp/client', 'miniapp/client']; return m; }); + assert.deepEqual(codes(dup.report.diagnostics), ['MINIAPP_ARTIFACTS_INVALID']); + const outside = await run((m) => { m.artifacts.client = ['./client']; return m; }); + assert.deepEqual(codes(outside.report.diagnostics), ['MINIAPP_ARTIFACTS_INVALID']); +}); + +test('miniapp: surface.path accepts a missing leading slash and rejects transport syntax', async () => { + const bare = await run((m) => { m.surface.path = 'dashboard'; return m; }); + assert.deepEqual(bare.report.diagnostics, []); + const url = await run((m) => { m.surface.path = 'https://x/y'; return m; }); + assert.deepEqual(codes(url.report.diagnostics), ['MINIAPP_SURFACE_INVALID']); +}); + +test('miniapp: mcpEndpoints shape and uniqueness', async () => { + const ok = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }]; return m; }); + assert.deepEqual(ok.report.diagnostics, []); + const dupPath = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }, { server: 'b', path: '/mcp' }]; return m; }); + assert.deepEqual(codes(dupPath.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); + const badServer = await run((m) => { m.mcpEndpoints = [{ server: 'has space', path: '/mcp' }]; return m; }); + assert.deepEqual(codes(badServer.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); +}); + +test('miniapp: hostConnectorAccess is validated for shape and reported as unverified', async () => { + const ok = await run((m) => { m.hostConnectorAccess = { providers: ['notion'] }; return m; }); + assert.deepEqual(codes(ok.report.diagnostics), ['HOST_CONNECTOR_UNVERIFIED']); + assert.equal(ok.report.diagnostics[0].level, 'warning'); + const bad = await run((m) => { m.hostConnectorAccess = { providers: ['Bad!'] }; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_HOST_CONNECTOR_INVALID']); +}); + +test('miniapp: unknown field and wrong schemaVersion', async () => { + const { report } = await run((m) => ({ ...m, schemaVersion: 2, extra: true })); + assert.deepEqual(codes(report.diagnostics), ['MINIAPP_UNKNOWN_FIELD', 'MINIAPP_SCHEMA_VERSION']); +}); From d72d57ad032fb69a9e8ec97812626c87870b48a8 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 12:32:16 +0800 Subject: [PATCH 05/14] feat(check): enforce portable package files, README headings, and Node entry conventions --- scripts/lib/rules/files.mjs | 58 ++++++++++++++++++++++++++++++++ scripts/lib/rules/node.mjs | 64 ++++++++++++++++++++++++++++++++++++ scripts/lib/rules/readme.mjs | 15 +++++++++ test/files.test.mjs | 61 ++++++++++++++++++++++++++++++++++ test/node.test.mjs | 54 ++++++++++++++++++++++++++++++ test/readme.test.mjs | 42 +++++++++++++++++++++++ 6 files changed, 294 insertions(+) create mode 100644 scripts/lib/rules/files.mjs create mode 100644 scripts/lib/rules/node.mjs create mode 100644 scripts/lib/rules/readme.mjs create mode 100644 test/files.test.mjs create mode 100644 test/node.test.mjs create mode 100644 test/readme.test.mjs diff --git a/scripts/lib/rules/files.mjs b/scripts/lib/rules/files.mjs new file mode 100644 index 0000000..13a5381 --- /dev/null +++ b/scripts/lib/rules/files.mjs @@ -0,0 +1,58 @@ +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { EXCLUDED_DIRECTORY, LIMITS } from '../contract.mjs'; +import { portablePathIssue } from '../paths.mjs'; + +export async function checkFiles(report, { packageDir }) { + const rootInfo = await lstat(packageDir); + if (rootInfo.isSymbolicLink()) { + report.error('PACKAGE_ROOT_SYMLINK', 'the package directory must not be a symbolic link'); + return; + } + const totals = { files: 0, bytes: 0 }; + await walk(report, packageDir, '', totals); + if (totals.files > LIMITS.maxFiles) { + report.error('PACKAGE_TOO_MANY_FILES', `${totals.files} files exceed the limit of ${LIMITS.maxFiles}`); + } + if (totals.bytes > LIMITS.maxTotalBytes) { + report.error('PACKAGE_TOO_LARGE', `${totals.bytes} bytes exceed the limit of ${LIMITS.maxTotalBytes}`); + } +} + +async function walk(report, absoluteDir, relativeDir, totals) { + const entries = await readdir(absoluteDir, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name, 'en')); + for (const entry of entries) { + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + const absolutePath = path.join(absoluteDir, entry.name); + if (entry.isDirectory() && entry.name.toLowerCase() === EXCLUDED_DIRECTORY) { + report.error('PACKAGE_EXCLUDED_DIRECTORY', 'node_modules must not be committed; vendor dependencies inside the payload instead', relativePath); + continue; + } + const issue = portablePathIssue(relativePath); + if (issue) report.error('PATH_NOT_PORTABLE', `path ${issue}`, relativePath); + const info = await lstat(absolutePath); + if (info.isSymbolicLink()) { + report.error('PACKAGE_SPECIAL_FILE', 'symbolic links are not allowed', relativePath); + continue; + } + if (info.isDirectory()) { + await walk(report, absolutePath, relativePath, totals); + continue; + } + if (!info.isFile()) { + report.error('PACKAGE_SPECIAL_FILE', 'only regular files and directories are allowed', relativePath); + continue; + } + if (info.nlink > 1) { + report.error('PACKAGE_SPECIAL_FILE', 'hard links are not allowed', relativePath); + continue; + } + totals.files += 1; + totals.bytes += info.size; + if (info.size > LIMITS.maxFileBytes) { + report.error('PACKAGE_FILE_TOO_LARGE', `${info.size} bytes exceed the per-file limit of ${LIMITS.maxFileBytes}`, relativePath); + } + } +} diff --git a/scripts/lib/rules/node.mjs b/scripts/lib/rules/node.mjs new file mode 100644 index 0000000..8408f1f --- /dev/null +++ b/scripts/lib/rules/node.mjs @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { START_EXPORT, STDOUT_CALL } from '../contract.mjs'; +import { pathExists } from '../fs.mjs'; + +const execFileAsync = promisify(execFile); +const SCRIPT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); + +export async function checkNode(report, { packageDir, entry, nodeRoots }) { + const scripts = new Set(); + for (const root of nodeRoots) { + const absolute = path.join(packageDir, ...root.split('/')); + if (await pathExists(absolute, 'directory')) { + for (const file of await collectScripts(absolute, root)) scripts.add(file); + } else if (SCRIPT_EXTENSIONS.has(path.extname(root)) && (await pathExists(absolute, 'file'))) { + scripts.add(root); + } + } + if (entry) scripts.add(entry); + + for (const relativePath of [...scripts].sort()) { + const absolute = path.join(packageDir, ...relativePath.split('/')); + if (!(await pathExists(absolute, 'file'))) continue; + const isEntry = relativePath === entry; + if (path.extname(relativePath) !== '.js') { + try { + await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true }); + } catch (error) { + report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath); + continue; + } + } + const source = await readFile(absolute, 'utf8'); + if (STDOUT_CALL.test(source)) { + const message = 'writes to stdout, which belongs to the Host; log through context.logger instead'; + if (isEntry) report.error('ENTRY_STDOUT', message, relativePath); + else report.warning('ENTRY_STDOUT', message, relativePath); + } + if (isEntry && !START_EXPORT.test(source)) { + report.error('ENTRY_START_EXPORT_MISSING', 'the Node entry must export a named start function using ESM syntax', relativePath); + } + } +} + +async function collectScripts(absoluteDir, relativeDir) { + const files = []; + for (const dirent of await readdir(absoluteDir, { withFileTypes: true })) { + const relativePath = `${relativeDir}/${dirent.name}`; + if (dirent.isDirectory()) { + if (dirent.name.toLowerCase() === 'node_modules') continue; + files.push(...(await collectScripts(path.join(absoluteDir, dirent.name), relativePath))); + } else if (dirent.isFile() && SCRIPT_EXTENSIONS.has(path.extname(dirent.name))) { + files.push(relativePath); + } + } + return files; +} + +function firstLine(text) { + return typeof text === 'string' ? text.split(/\r?\n/u).find((line) => line.trim()) ?? '' : ''; +} diff --git a/scripts/lib/rules/readme.mjs b/scripts/lib/rules/readme.mjs new file mode 100644 index 0000000..1e9288a --- /dev/null +++ b/scripts/lib/rules/readme.mjs @@ -0,0 +1,15 @@ +import path from 'node:path'; + +import { README_HEADINGS } from '../contract.mjs'; +import { readText } from '../fs.mjs'; + +export async function checkReadme(report, { packageDir }) { + const text = await readText(path.join(packageDir, 'README.md')); + if (text === undefined) return; + const headings = new Set(text.split(/\r?\n/u).map((line) => line.trim())); + for (const heading of README_HEADINGS) { + if (!headings.has(heading)) { + report.warning('README_HEADING_MISSING', `README.md should contain the heading "${heading}"`, 'README.md'); + } + } +} diff --git a/test/files.test.mjs b/test/files.test.mjs new file mode 100644 index 0000000..f2b7524 --- /dev/null +++ b/test/files.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { checkFiles } from '../scripts/lib/rules/files.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot } from './helpers.mjs'; + +async function run(prepare) { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const target = (await prepare?.(dir, root)) ?? dir; + const report = createReport(); + await checkFiles(report, { packageDir: target }); + return report.diagnostics; +} + +test('files: baseline passes', async () => { + assert.deepEqual(await run(), []); +}); + +test('files: node_modules anywhere is one error per directory, not per file', async () => { + const diagnostics = await run(async (dir) => { + await mkdir(path.join(dir, 'miniapp', 'node', 'node_modules', 'dep'), { recursive: true }); + await writeFile(path.join(dir, 'miniapp', 'node', 'node_modules', 'dep', 'index.js'), ''); + await writeFile(path.join(dir, 'miniapp', 'node', 'node_modules', 'dep', 'package.json'), '{}'); + }); + assert.deepEqual(codes(diagnostics), ['PACKAGE_EXCLUDED_DIRECTORY']); + assert.equal(diagnostics[0].path, 'miniapp/node/node_modules'); +}); + +test('files: non-ASCII and Windows-reserved names are errors', async () => { + const diagnostics = await run(async (dir) => { + await writeFile(path.join(dir, '预览.png'), ''); + await writeFile(path.join(dir, 'con.txt'), ''); + }); + assert.deepEqual(codes(diagnostics).sort(), ['PATH_NOT_PORTABLE', 'PATH_NOT_PORTABLE']); +}); + +test('files: a symlink inside the package is an error', async () => { + const diagnostics = await run(async (dir) => { + await symlink(path.join(dir, 'README.md'), path.join(dir, 'README-link.md')); + }); + assert.deepEqual(codes(diagnostics), ['PACKAGE_SPECIAL_FILE']); +}); + +test('files: a package root that is itself a symlink is rejected before walking', async () => { + const diagnostics = await run(async (dir, root) => { + const link = path.join(root, 'plugins', 'tester', 'linked'); + await symlink(dir, link); + return link; + }); + assert.deepEqual(codes(diagnostics), ['PACKAGE_ROOT_SYMLINK']); +}); + +test('files: a single file over 16 MiB is an error', async () => { + const diagnostics = await run(async (dir) => { + await writeFile(path.join(dir, 'big.bin'), Buffer.alloc(16 * 1024 * 1024 + 1)); + }); + assert.deepEqual(codes(diagnostics), ['PACKAGE_FILE_TOO_LARGE']); +}); diff --git a/test/node.test.mjs b/test/node.test.mjs new file mode 100644 index 0000000..acab22a --- /dev/null +++ b/test/node.test.mjs @@ -0,0 +1,54 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { checkNode } from '../scripts/lib/rules/node.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot } from './helpers.mjs'; + +const ENTRY = 'miniapp/node/server.mjs'; + +async function run(prepare, { entry = ENTRY, nodeRoots = ['miniapp/node'] } = {}) { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await prepare?.(dir); + const report = createReport(); + await checkNode(report, { packageDir: dir, entry, nodeRoots }); + return report.diagnostics; +} + +test('node: baseline passes', async () => { + assert.deepEqual(await run(), []); +}); + +test('node: stdout calls in the entry are errors, in other files warnings', async () => { + const entryHit = await run((dir) => appendFile(path.join(dir, ENTRY), "\nconsole.info('x');\n")); + assert.deepEqual(codes(entryHit), ['ENTRY_STDOUT']); + assert.equal(entryHit[0].level, 'error'); + const vendorHit = await run(async (dir) => { + await mkdir(path.join(dir, 'miniapp', 'node', 'vendor')); + await writeFile(path.join(dir, 'miniapp', 'node', 'vendor', 'x.mjs'), "export const v = 1;\nconsole.log('x');\n"); + }); + assert.deepEqual(codes(vendorHit), ['ENTRY_STDOUT']); + assert.equal(vendorHit[0].level, 'warning'); +}); + +test('node: entry must export start with ESM syntax', async () => { + const diagnostics = await run(async (dir) => { + const file = path.join(dir, ENTRY); + await writeFile(file, (await readFile(file, 'utf8')).replace('export async function start', 'async function start')); + }); + assert.deepEqual(codes(diagnostics), ['ENTRY_START_EXPORT_MISSING']); +}); + +test('node: a syntax error in an .mjs file is an error', async () => { + const diagnostics = await run((dir) => appendFile(path.join(dir, ENTRY), '\nthis is not javascript\n')); + assert.deepEqual(codes(diagnostics), ['ENTRY_SYNTAX']); +}); + +test('node: .js files skip the syntax check and rely on the pattern checks only', async () => { + const diagnostics = await run(async (dir) => { + await writeFile(path.join(dir, 'miniapp', 'node', 'helper.js'), 'module.exports = { broken: ( };\n'); + }); + assert.deepEqual(diagnostics, []); +}); diff --git a/test/readme.test.mjs b/test/readme.test.mjs new file mode 100644 index 0000000..108aa07 --- /dev/null +++ b/test/readme.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { checkReadme } from '../scripts/lib/rules/readme.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot } from './helpers.mjs'; + +async function run(transform) { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const file = path.join(dir, 'README.md'); + if (transform) await writeFile(file, transform(await readFile(file, 'utf8'))); + const report = createReport(); + await checkReadme(report, { packageDir: dir }); + return report.diagnostics; +} + +test('readme: baseline has both headings', async () => { + assert.deepEqual(await run(), []); +}); + +test('readme: CRLF line endings still match', async () => { + assert.deepEqual(await run((text) => text.replace(/\n/gu, '\r\n')), []); +}); + +test('readme: each missing heading is one warning', async () => { + const diagnostics = await run((text) => text.replace('## Data & access', '## Privacy')); + assert.deepEqual(codes(diagnostics), ['README_HEADING_MISSING']); + assert.equal(diagnostics[0].level, 'warning'); + assert.match(diagnostics[0].message, /## Data & access/u); +}); + +test('readme: missing README is not reported here (layout owns it)', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const { rm } = await import('node:fs/promises'); + await rm(path.join(dir, 'README.md')); + const report = createReport(); + await checkReadme(report, { packageDir: dir }); + assert.deepEqual(report.diagnostics, []); +}); From bd60b009a58b36f1132f15cf46c49fbe8e9e9953 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 12:44:45 +0800 Subject: [PATCH 06/14] feat(check): add package orchestration, repository-level checks, and the validate CLI --- scripts/lib/package.mjs | 53 +++++++++++++++++++++++++++++++++++++ scripts/lib/repo.mjs | 57 ++++++++++++++++++++++++++++++++++++++++ scripts/validate.mjs | 58 +++++++++++++++++++++++++++++++++++++++++ test/package.test.mjs | 53 +++++++++++++++++++++++++++++++++++++ test/repo.test.mjs | 50 +++++++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 scripts/lib/package.mjs create mode 100644 scripts/lib/repo.mjs create mode 100644 scripts/validate.mjs create mode 100644 test/package.test.mjs create mode 100644 test/repo.test.mjs diff --git a/scripts/lib/package.mjs b/scripts/lib/package.mjs new file mode 100644 index 0000000..d106262 --- /dev/null +++ b/scripts/lib/package.mjs @@ -0,0 +1,53 @@ +import path from 'node:path'; + +import { readJsonFile } from './fs.mjs'; +import { createReport } from './report.mjs'; +import { checkFiles } from './rules/files.mjs'; +import { checkImageFile, checkLayout, checkNameMatchesDirectory } from './rules/layout.mjs'; +import { checkManifest } from './rules/manifest.mjs'; +import { checkMcode } from './rules/mcode.mjs'; +import { checkMiniApp } from './rules/miniapp.mjs'; +import { checkNode } from './rules/node.mjs'; +import { checkReadme } from './rules/readme.mjs'; + +export async function validatePackage(packageDir, { requireLowercaseAuthor = true } = {}) { + const dir = path.resolve(packageDir); + const report = createReport(); + + await checkLayout(report, { packageDir: dir, requireLowercaseAuthor }); + + const manifest = await readJson(report, dir, '.minimax-plugin/plugin.json', 'MANIFEST_UNREADABLE'); + if (manifest.present) { + const identity = await checkManifest(report, { packageDir: dir, manifest: manifest.value }); + await checkNameMatchesDirectory(report, { packageDir: dir, name: identity.name }); + if (identity.icon) await checkImageFile(report, { packageDir: dir, relativePath: identity.icon, label: 'icon' }); + if (identity.darkIcon) await checkImageFile(report, { packageDir: dir, relativePath: identity.darkIcon, label: 'darkIcon' }); + } + + const packageJson = await readJson(report, dir, 'package.json', 'MCODE_UNREADABLE'); + if (packageJson.present) checkMcode(report, { packageJson: packageJson.value }); + + const miniapp = await readJson(report, dir, 'miniapp/miniapp.json', 'MINIAPP_UNREADABLE'); + let resolved = { nodeRoots: [], clientRoots: [] }; + if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value }); + + await checkFiles(report, { packageDir: dir }); + await checkReadme(report, { packageDir: dir }); + await checkNode(report, { packageDir: dir, entry: resolved.entry, nodeRoots: resolved.nodeRoots }); + + return report.diagnostics; +} + +export async function readPackageName(packageDir) { + const result = await readJsonFile(path.join(packageDir, '.minimax-plugin', 'plugin.json')); + return result.ok && result.value !== null && typeof result.value === 'object' && typeof result.value.name === 'string' + ? result.value.name + : undefined; +} + +async function readJson(report, dir, relativePath, code) { + const result = await readJsonFile(path.join(dir, ...relativePath.split('/'))); + if (result.ok) return { present: true, value: result.value }; + if (result.reason !== 'missing') report.error(code, `${relativePath} ${result.detail}`, relativePath); + return { present: false }; +} diff --git a/scripts/lib/repo.mjs b/scripts/lib/repo.mjs new file mode 100644 index 0000000..93842f9 --- /dev/null +++ b/scripts/lib/repo.mjs @@ -0,0 +1,57 @@ +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { readText } from './fs.mjs'; +import { readPackageName } from './package.mjs'; +import { createReport } from './report.mjs'; + +const ROOT_READMES = ['README.md', 'README.zh-CN.md']; + +export async function discoverPackages(rootDir) { + const packages = []; + for (const author of await listDirs(path.join(rootDir, 'plugins'))) { + for (const id of await listDirs(path.join(rootDir, 'plugins', author))) { + packages.push({ dir: path.join(rootDir, 'plugins', author, id), kind: 'plugin', label: `plugins/${author}/${id}` }); + } + } + for (const id of await listDirs(path.join(rootDir, 'examples'))) { + packages.push({ dir: path.join(rootDir, 'examples', id), kind: 'example', label: `examples/${id}` }); + } + return packages; +} + +export async function validateRepository(rootDir, packages) { + const report = createReport(); + const owners = new Map(); + for (const pkg of packages) { + const name = await readPackageName(pkg.dir); + if (name === undefined) continue; + const owner = owners.get(name); + if (owner !== undefined && owner !== pkg.label) { + report.error('REPO_DUPLICATE_NAME', `plugin name "${name}" is used by both ${owner} and ${pkg.label}; plugin IDs must be unique across the repository`); + } else { + owners.set(name, pkg.label); + } + } + const texts = await Promise.all(ROOT_READMES.map((file) => readText(path.join(rootDir, file)))); + for (const pkg of packages) { + if (pkg.kind !== 'plugin') continue; + ROOT_READMES.forEach((file, index) => { + const text = texts[index]; + if (text !== undefined && !text.includes(`${pkg.label}/`)) { + report.error('REPO_README_LINK_MISSING', `${file} has no link to ${pkg.label}/; add a row to its MiniApps table`, file); + } + }); + } + return report.diagnostics; +} + +async function listDirs(dir) { + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..dcc2416 --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { validatePackage } from './lib/package.mjs'; +import { discoverPackages, validateRepository } from './lib/repo.mjs'; +import { formatReport, hasErrors } from './lib/report.mjs'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function main(args) { + let failed = false; + let packages; + if (args.length > 0) { + const explicit = await explicitPackages(args); + failed = explicit.failed; + packages = explicit.packages; + } else { + packages = await discoverPackages(rootDir); + } + if (packages.length === 0) { + console.error('No packages found under plugins/*/* or examples/*.'); + return 1; + } + for (const pkg of packages) { + const diagnostics = await validatePackage(pkg.dir, { requireLowercaseAuthor: pkg.kind === 'plugin' }); + console.log(formatReport(pkg.label, diagnostics)); + if (hasErrors(diagnostics)) failed = true; + } + if (args.length === 0) { + const diagnostics = await validateRepository(rootDir, packages); + console.log(formatReport('repository', diagnostics)); + if (hasErrors(diagnostics)) failed = true; + } + return failed ? 1 : 0; +} + +async function explicitPackages(args) { + const packages = []; + let failed = false; + for (const arg of args) { + const dir = path.resolve(arg); + const info = await stat(dir).catch(() => undefined); + if (!info || !info.isDirectory()) { + console.error(`Not a directory: ${arg}`); + failed = true; + continue; + } + const relative = path.relative(rootDir, dir); + const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + const label = inside ? relative.split(path.sep).join('/') : path.basename(dir); + packages.push({ dir, kind: label.startsWith('examples/') ? 'example' : 'plugin', label }); + } + return { packages, failed }; +} + +process.exitCode = await main(process.argv.slice(2)); diff --git a/test/package.test.mjs b/test/package.test.mjs new file mode 100644 index 0000000..ad25cb0 --- /dev/null +++ b/test/package.test.mjs @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { readPackageName, validatePackage } from '../scripts/lib/package.mjs'; +import { hasErrors } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot } from './helpers.mjs'; + +test('package: the example is a clean baseline', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + assert.deepEqual(await validatePackage(dir), []); + assert.equal(await readPackageName(dir), 'hello-miniapp'); +}); + +test('package: a UTF-8 BOM in plugin.json is reported by name', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const file = path.join(dir, '.minimax-plugin', 'plugin.json'); + await writeFile(file, `\uFEFF${await readFile(file, 'utf8')}`); + const diagnostics = await validatePackage(dir); + assert.deepEqual(codes(diagnostics), ['MANIFEST_UNREADABLE']); + assert.match(diagnostics[0].message, /UTF-8 BOM/u); +}); + +test('package: directory renamed away from the manifest name', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root, { dirName: 'renamed' }); + assert.deepEqual(codes(await validatePackage(dir)), ['LAYOUT_NAME_MISMATCH']); +}); + +test('package: a missing miniapp.json is reported once by layout, not twice', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await rm(path.join(dir, 'miniapp', 'miniapp.json')); + assert.deepEqual(codes(await validatePackage(dir)), ['LAYOUT_FILE_MISSING']); +}); + +test('package: warnings alone do not make hasErrors true', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await mkdir(path.join(dir, 'miniapp', 'node', 'vendor')); + await writeFile(path.join(dir, 'miniapp', 'node', 'vendor', 'lib.mjs'), "console.log('vendored');\n"); + const diagnostics = await validatePackage(dir); + assert.deepEqual(codes(diagnostics), ['ENTRY_STDOUT']); + assert.equal(hasErrors(diagnostics), false); +}); + +test('package: an example is not subject to the lowercase-author rule', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root, { author: 'Examples' }); + assert.deepEqual(await validatePackage(dir, { requireLowercaseAuthor: false }), []); +}); diff --git a/test/repo.test.mjs b/test/repo.test.mjs new file mode 100644 index 0000000..77ae74a --- /dev/null +++ b/test/repo.test.mjs @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { discoverPackages, validateRepository } from '../scripts/lib/repo.mjs'; +import { codes, copyExample, editJson, makeTmpRoot } from './helpers.mjs'; + +async function readmes(root, names) { + const rows = names.map((n) => `| [${n.id}](plugins/${n.author}/${n.id}/) | x | [${n.author}](https://github.com/${n.author}) |`).join('\n'); + await writeFile(path.join(root, 'README.md'), `# Repo\n\n| MiniApp | What | Author |\n| --- | --- | --- |\n${rows}\n`); + const zh = names.map((n) => `| [${n.id}](plugins/${n.author}/${n.id}/README.zh-CN.md) | x | [${n.author}](https://github.com/${n.author}) |`).join('\n'); + await writeFile(path.join(root, 'README.zh-CN.md'), `# 仓库\n\n| MiniApp | 功能 | 作者 |\n| --- | --- | --- |\n${zh}\n`); +} + +test('repo: discoverPackages lists plugins by author and examples, sorted', async () => { + const root = await makeTmpRoot(); + await copyExample(root, { author: 'bob', dirName: 'hello-miniapp' }); + await copyExample(root, { author: 'alice', dirName: 'hello-miniapp' }); + const { cp } = await import('node:fs/promises'); + await cp(path.join(root, 'plugins', 'bob', 'hello-miniapp'), path.join(root, 'examples', 'hello-miniapp'), { recursive: true }); + const packages = await discoverPackages(root); + assert.deepEqual(packages.map((p) => [p.kind, p.label]), [ + ['plugin', 'plugins/alice/hello-miniapp'], + ['plugin', 'plugins/bob/hello-miniapp'], + ['example', 'examples/hello-miniapp'], + ]); +}); + +test('repo: duplicate plugin names across packages are an error', async () => { + const root = await makeTmpRoot(); + await copyExample(root, { author: 'alice' }); + await copyExample(root, { author: 'bob' }); + await readmes(root, [{ author: 'alice', id: 'hello-miniapp' }, { author: 'bob', id: 'hello-miniapp' }]); + const diagnostics = await validateRepository(root, await discoverPackages(root)); + assert.deepEqual(codes(diagnostics), ['REPO_DUPLICATE_NAME']); +}); + +test('repo: every plugin needs a link in both root READMEs; examples do not', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root, { author: 'alice', dirName: 'alpha' }); + await editJson(path.join(dir, '.minimax-plugin', 'plugin.json'), (m) => ({ ...m, name: 'alpha' })); + const { cp } = await import('node:fs/promises'); + await cp(dir, path.join(root, 'examples', 'hello-miniapp'), { recursive: true }); + await editJson(path.join(root, 'examples', 'hello-miniapp', '.minimax-plugin', 'plugin.json'), (m) => ({ ...m, name: 'hello-miniapp' })); + await readmes(root, []); + const missing = await validateRepository(root, await discoverPackages(root)); + assert.deepEqual(codes(missing), ['REPO_README_LINK_MISSING', 'REPO_README_LINK_MISSING']); + await readmes(root, [{ author: 'alice', id: 'alpha' }]); + assert.deepEqual(await validateRepository(root, await discoverPackages(root)), []); +}); From f23abe43ac041f228d5e84b42303dd0ba4dcf7f2 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 12:51:20 +0800 Subject: [PATCH 07/14] ci: run the package checks and validator tests on every pull request --- .github/workflows/ci.yml | 25 +++++++++++++++++++++ test/wording.test.mjs | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 test/wording.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e91d6f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + name: check (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check diff --git a/test/wording.test.mjs b/test/wording.test.mjs new file mode 100644 index 0000000..1b45a3c --- /dev/null +++ b/test/wording.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { REPO_ROOT } from './helpers.mjs'; + +const RETIRED_TERM = new RegExp(['live', 'board'].join(''), 'iu'); +const SCAN = [ + 'AGENTS.md', + 'CONTRIBUTING.md', + 'CONTRIBUTING.zh-CN.md', + 'README.md', + 'README.zh-CN.md', + 'package.json', + 'docs', + 'examples', + 'scripts', + 'test', + '.github', +]; +const BINARY = /\.(?:png|jpe?g|webp|ico)$/u; + +async function* walk(target) { + let entries; + try { + entries = await readdir(target, { withFileTypes: true }); + } catch { + yield target; + return; + } + for (const entry of entries) { + if (entry.name === 'node_modules') continue; + yield* walk(path.join(target, entry.name)); + } +} + +test('repository-owned files use only the current product vocabulary', async () => { + const hits = []; + for (const start of SCAN) { + for await (const file of walk(path.join(REPO_ROOT, start))) { + if (BINARY.test(file)) continue; + const text = await readFile(file, 'utf8').catch(() => undefined); + if (text !== undefined && RETIRED_TERM.test(text)) hits.push(path.relative(REPO_ROOT, file)); + } + } + assert.deepEqual(hits, [], `retired vocabulary found in: ${hits.join(', ')}`); +}); From 45f57d9a293551d4444a1ce9e49cab0c0e5bb78c Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 13:00:07 +0800 Subject: [PATCH 08/14] docs: publish the Mini App package contract --- docs/package-contract.md | 127 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/package-contract.md diff --git a/docs/package-contract.md b/docs/package-contract.md new file mode 100644 index 0000000..ceab2cf --- /dev/null +++ b/docs/package-contract.md @@ -0,0 +1,127 @@ +# Mini App package contract + +Verified against MiniMax Code 3.0.73. + +A Mini App is a MiniMax Plugin whose `package.json` declares a Mini App payload. Everything below is +enforced by MiniMax Code when the package is installed; `npm run check` enforces the same rules here. + +## Layout + +```text +/ + .minimax-plugin/plugin.json Plugin manifest: identity, icon, category, declared capabilities + package.json Mini App declaration (see below) + icon.png Plugin icon (PNG, JPEG, or WebP) + miniapp/ + miniapp.json Payload roots, Node entry, page route + client/ Runtime payload: files served to the page + node/ Runtime payload: the Node entry and what it imports + README.md Required by this repository + LICENSE Required by this repository + README.zh-CN.md Optional + tests/ Optional; keep outside miniapp/ + skills//SKILL.md Optional plugin capability + *.mcp.json Optional plugin capability + bindings/.binding.json Optional plugin capability +``` + +The directory name, `plugin.json.name`, and the installed plugin ID are the same string. It must be +unique across this repository because the author directory is dropped at install time. + +Git does not keep empty directories. Every path listed under `artifacts` must contain at least one +committed file. + +## `.minimax-plugin/plugin.json` + +| Field | Rule | +| --- | --- | +| `$schema` | Optional string. | +| `schemaVersion` | The number `1`. | +| `name` | At most 80 characters, matches `^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`, equals the directory name. | +| `displayName` | Optional non-empty string. | +| `version` | SemVer 2.0, at most 128 characters. | +| `description`, `author` | Non-empty strings. | +| `icon` | Relative path with a lowercase `.png`, `.jpg`, `.jpeg`, or `.webp` extension; the file must exist and be a real image. | +| `darkIcon` | Optional; same rules as `icon`. | +| `category` | One of `Office`, `Studio`, `Design & Sites`, `Code`, `Business`, `Sales`, `Productivity`, `Science & Healthcare`, `Education`, `Other`. | +| `exampleQueries` | Array of non-empty strings. Provide at least one; the Agent uses them to open the Mini App by name. | +| `apps` | Array of `*.app.json` paths. Locally installed packages ignore this field; use `[]`. | +| `mcpServers` | Array of `*.mcp.json` paths; each file must exist. These are MCP servers the plugin offers to the Agent, distinct from `mcpEndpoints` in `miniapp.json`. | +| `skills` | Array of `skills//SKILL.md` paths; each file must exist. | +| `hooks` | Optional array of `*.json` paths; each file must exist. Contents are validated by MiniMax Code at install time. | +| `hostBindings` | Optional array of `bindings/.binding.json` paths; each file must exist. Contents are validated at install time. | +| Any other field | Rejected. | + +Use `[]` for `apps`, `mcpServers`, and `skills` when the package has none. + +## `package.json` + +The `mcode` field must be exactly: + +```json +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} +``` + +No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, `scripts`) are fine. + +## `miniapp/miniapp.json` + +```json +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { "path": "/dashboard" }, + "mcpEndpoints": [] +} +``` + +- `schemaVersion` is `1`. Unknown top-level fields are rejected. +- `artifacts.client` and `artifacts.node` are non-empty arrays of unique paths under `miniapp/`. + Each path must exist. These are the runtime payload roots: exactly what MiniMax Code hashes and + installs. Any `node_modules` directory is excluded from payloads. +- `runtime.kind` is `process`. `runtime.entry` ends in `.js`, `.mjs`, or `.cjs`, exists, and lies + inside one of `artifacts.node`. `runtime.lifecycle` is `on-demand` or omitted. +- `surface.path` is the route the Node entry serves the page on. It is relative to the Host and + must not contain an origin, query, fragment, or backslash. A missing leading `/` is added. +- `mcpEndpoints` is an array of `{ "server": string, "path": string }`. `server` matches + `^[a-zA-Z0-9_-]{1,128}$` and must name a server declared through `plugin.json.mcpServers`; MiniMax + Code checks that reference at install time. `server` and `path` are each unique. Use `[]` when + there are none. +- `hostConnectorAccess` is optional: `{ "providers": string[] }`, each matching + `^[a-z0-9_-]{1,64}$` and unique. Declared providers are granted by the Host at install time; this + repository does not document their use. + +## Portable paths + +Every path inside the package, and every path written in a manifest, must be portable: + +- ASCII only; each segment matches `[A-Za-z0-9._-]+` and does not end with `.`. +- No `.` or `..` segments, no backslashes, no leading or trailing `/`, no control characters. +- Each segment at most 128 bytes; the whole path at most 512 bytes and at most 16 segments. +- A segment's part before its first `.` must not be a Windows reserved name (`con`, `prn`, `aux`, + `nul`, `com1`–`com9`, `lpt1`–`lpt9`). +- No symbolic links or hard links anywhere in the package, and the package directory itself is not + a symbolic link. + +## Package limits + +At most 1024 files, 16 MiB per file, and 64 MiB in total. + +## Runtime payload + +Only `artifacts.client` and `artifacts.node` become the runtime payload. Keep tests, docs, and +source-only files outside `miniapp/`. Vendor any third-party runtime code inside a payload root; +`node_modules` is never committed to this repository. From cc0958404a2ace4da54b75bfd0cdde944c84eeb2 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 13:01:35 +0800 Subject: [PATCH 09/14] docs: publish the Mini App Node runtime and security rules --- docs/runtime.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/security.md | 32 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 docs/runtime.md create mode 100644 docs/security.md diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..3e1064d --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,65 @@ +# Mini App Node runtime + +Verified against MiniMax Code 3.0.73. + +## Loading + +MiniMax Code imports `runtime.entry` as an ES module from a `file:` URL and requires a named export +`start`. The entry's real path must be inside the package. The manifest accepts `.js`, `.mjs`, and +`.cjs`; this repository requires ESM export syntax, so use `.mjs` and do not depend on +`package.json#type`. + +```js +export async function start(context) { + // install routes, start the server, return { dispose } +} +``` + +`miniapp/node/miniapp-api.ts` in `examples/hello-miniapp/` declares the types below. Copy it next +to your entry for editor type checking; it is never imported at runtime. + +## `context` + +| Field | Meaning | +| --- | --- | +| `pluginId` | The plugin ID (`plugin.json.name`). | +| `pluginRoot` | Real path of the installed package. Read your Client files from here. | +| `dataDir` | a private directory the Host creates and owns for this plugin; its location is opaque and may change; there is no supported way to reach Host files from it. Store durable state here. | +| `listen` | `{ host: "127.0.0.1", port }`. Bind exactly this address; never pick your own port. | +| `signal` | An `AbortSignal` that fires when the Host stops the Mini App. | +| `logger` | `debug` / `info` / `warn` / `error` `(message, fields?)`. Messages are truncated at 4 KiB. Only the **keys** of `fields` leave the process; values stay local, so put diagnostic detail in the message. | +| `hostConnector` | May be absent. Its use is outside the scope of this repository. | + +## `start(context)` + +- Install every route and start listening before resolving. Resolution is the readiness signal; + there is no health route. +- Return `{ dispose }` or `undefined`. An object without `dispose` is rejected as an invalid + lifecycle. Throwing fails the start; a port collision is reported as its own error. +- Do not fetch business data inside `start`. Register handlers and resolve. + +## `dispose()` + +Close everything the entry started: the HTTP server, timers, child processes, streams, and any +open file or database handles. The Host stops only the entry process; it does not discover or +terminate processes the entry spawned. + +## stdout and stdin + +Both belong to the Host. Never call `console.log`, `console.info`, `console.debug`, +`console.dir`, `console.table`, or `process.stdout.write`; log through `context.logger`. + +## Lifecycle + +The process starts on demand and may be stopped at any time. Only a small number of Mini Apps run at +once, so assume yours can be stopped and restarted between two page views: keep durable state in +`dataDir`, not in memory. + +## Client + +Serve the page yourself on `surface.path`; a static HTML file is a complete Client. The Client calls +only the routes your Node entry exposes. + +## Platforms + +MiniMax Code runs on macOS and Windows. Use `node:path` for paths and avoid Unix-only commands. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..0e562d9 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,32 @@ +# Mini App security rules + +Verified against MiniMax Code 3.0.73. + +## Process boundary + +- Bind only `context.listen.host` and `context.listen.port`. Serve `surface.path` and your own + routes; do not proxy arbitrary URLs or arbitrary files. +- Validate every request parameter. Treat the page as untrusted input to the Node process. +- Secrets stay in the Node process. Never place credentials in HTML, Client JavaScript, log + messages, or error responses. + +## State + +- Store durable state under `context.dataDir`. +- Browser storage may hold view preferences (filters, sort order, expanded rows), not domain data. + +## Writing outside `dataDir` + +A Mini App that edits Host or user files must document, in its README, which files it writes and +how. Write atomically (temp file in the same directory, then rename), preserve the original file +mode, serialize concurrent writes, and make Undo refuse when the file changed underneath it. Back up +before bulk edits and say where the backups go. + +## Spawning processes + +Disclose every command in the README. Pass arguments as an array, never through a shell, and never +place user input in a command line. + +## Network + +Disclose every outbound host in the README. The default is none. From 37b1983b686842af97d67577cea37ad82da1e437 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 23 Sep 2026 13:02:13 +0800 Subject: [PATCH 10/14] docs: add AGENTS.md so coding agents load the Mini App contract automatically --- AGENTS.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5592089 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md + +This repository hosts community Mini App packages for MiniMax Code. One task dominates: add or +update one package at `plugins///`. + +## Add or update a Mini App + +1. New package: copy `examples/hello-miniapp/` to `plugins///`, then replace + `name`, `displayName`, `description`, `author`, `exampleQueries`, the ``, the README, and + the LICENSE holder. Update: open the existing package. Read `docs/package-contract.md` before + editing any manifest. +2. Read `docs/runtime.md` before editing `miniapp/node/*`. Read `docs/security.md` before the + Node code reads or writes files, spawns processes, or makes network requests. +3. Write `README.md` (English; `README.zh-CN.md` optional): what it does, how to install and open + it, and the headings `## Tested environment` and `## Data & access` (files read/written, + network hosts, spawned processes, where state is stored). +4. Add one row to the table in both root READMEs. +5. Run `npm run check`. Done when it reports no errors and every warning is either fixed or + explained in the PR description. + +## Hard rules + +- `context.dataDir` is your private state directory, created and owned by the Host. Store durable + state there and treat its location as opaque. Reading Host files has no supported API; a plugin + that does so must state in its README which files, how it locates them, and that this relies on + unspecified layout. +- stdout and stdin belong to the Host. Log through `context.logger`. +- The Node entry is ESM with a named export `start(context)`. Resolve `start` only after the + listener accepts connections on `context.listen`. Return `{ dispose }`; `dispose` closes + everything the entry started: server, timers, child processes, streams, file handles. +- Bind only `context.listen.host` / `context.listen.port`. Serve `surface.path` plus your own + routes. +- A Mini App package is a MiniMax Plugin. Skills, MCP servers, hooks, host bindings, MCP endpoints, + and `hostConnectorAccess` may all be declared; `npm run check` validates their shape only, and the + Host validates them at install time. This repository documents the Mini App payload; treat + `context.hostConnector` and those capabilities as outside its scope. +- The runtime payload is exactly `miniapp/client` and `miniapp/node`. Keep tests and docs outside + `miniapp/`. Vendor third-party code inside the payload; `node_modules` is never committed. +- Paths are portable: ASCII, no symlinks, no `..`. `npm run check` enforces the full rule set. +- Secrets stay in the Node process: never in HTML, Client JavaScript, logs, or error responses. +- Plugin ID = directory name = `plugin.json.name`, unique across the repository. + +## Review policy + +Maintainers gate on three things: manifests pass `npm run check`; the README describes real +behaviour (files, network, processes, tested environment); the package cannot damage Host or user +data. Everything else is a suggestion. + +## Navigation + +- `docs/package-contract.md` — layout, the three manifests, portable paths, limits +- `docs/runtime.md` — `start(context)`, `dispose`, logger, lifecycle +- `docs/security.md` — process boundary, state, writing outside `dataDir`, spawning, network +- `examples/hello-miniapp/` — the copyable starting point +- `CONTRIBUTING.md` — fork, pull request, and license steps for people From bb8309f0577242d327db5e3a0d452e1a989dcffa Mon Sep 17 00:00:00 2001 From: dazhi <dazhi@minimaxi.com> Date: Wed, 23 Sep 2026 13:04:52 +0800 Subject: [PATCH 11/14] docs: point contributors at the example package, the contract docs, and npm run check --- CONTRIBUTING.md | 6 ++++-- CONTRIBUTING.zh-CN.md | 6 ++++-- README.md | 4 ++-- README.zh-CN.md | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ad01c5..f1fb04e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,15 @@ English | [简体中文](CONTRIBUTING.zh-CN.md) We welcome tools, games, and other interesting apps you have built for MiniMax Code. 1. Fork this repository and add your complete plugin package under `plugins/<your-github-username>/<plugin-id>/`. Use a lowercase GitHub username for the author directory. -2. Include the hidden `.minimax-plugin/` directory, `package.json`, `miniapp/`, and all required runtime assets. The plugin must work when copied on its own, without depending on other directories in this repository. +2. Start from `examples/hello-miniapp/`. Include the hidden `.minimax-plugin/` directory, `package.json`, `miniapp/`, and all required runtime assets. The plugin must work when copied on its own, without depending on other directories in this repository. The exact rules are in `docs/package-contract.md`, `docs/runtime.md`, and `docs/security.md`. 3. Add a short English `README.md`. You may also include `README.zh-CN.md` with links between the two versions. Explain what the app does, how to install and use it, tested client versions and operating systems, required configuration, file access, and network requests. Screenshots or GIFs are welcome. 4. Include a `LICENSE` you are entitled to use, and preserve required attribution for third-party code and assets. -5. Add an entry to the app tables in both root READMEs and open a pull request. +5. Add an entry to the app tables in both root READMEs, run `npm run check` (Node.js 22 or later) until it reports no errors, and open a pull request. The plugin directory name must match `name` in `.minimax-plugin/plugin.json`. **Plugin IDs must be unique across the repository**, since the author directory is not kept during installation. If a name is already taken, consider adding an author prefix. Submit ready-to-run files. If the app requires a build step, include its source and build instructions. Do not commit `node_modules/`, credentials, real session records, personal data, or runtime caches. Use synthetic or thoroughly anonymized data in screenshots and examples. Before submitting, install the app in MiniMax Code, open it, and check its main features. Include your test environment and results in the pull request, and state any unverified behavior. When updating an existing app, keep its plugin ID and update the version and usage instructions as appropriate. + +Working with an AI coding agent? It reads `AGENTS.md` automatically; `docs/` holds the same rules for people. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 8ffbce0..916f102 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -5,13 +5,15 @@ 欢迎分享你为 MiniMax Code 制作的小工具、游戏和其他有趣作品。 1. Fork 本仓库,在 `plugins/<你的 GitHub username>/<plugin-id>/` 下放入完整插件包。用户名目录统一使用小写。 -2. 保留 `.minimax-plugin/` 隐藏目录、`package.json`、`miniapp/` 和运行所需资源。插件应能独立复制使用,不依赖仓库里的其他目录。 +2. 从 `examples/hello-miniapp/` 复制开始。保留 `.minimax-plugin/` 隐藏目录、`package.json`、`miniapp/` 和运行所需资源。插件应能独立复制使用,不依赖仓库里的其他目录。具体规则见 `docs/package-contract.md`、`docs/runtime.md` 和 `docs/security.md`(英文)。 3. 添加简短的英文 `README.md`,可另附 `README.zh-CN.md` 并互相链接。说明用途、安装与使用方式、已验证的客户端版本和系统,以及需要的配置、文件访问或网络请求。欢迎附上截图或 GIF。 4. 添加你有权使用的 `LICENSE`,保留第三方代码和素材要求的署名。 -5. 在根目录中英文 README 的作品表格中各增加一行,提交 Pull Request。 +5. 在根目录中英文 README 的作品表格中各增加一行,运行 `npm run check`(需要 Node.js 22 或更高版本)直到没有 error,再提交 Pull Request。 插件目录名应与 `.minimax-plugin/plugin.json` 的 `name` 一致。**插件 ID 在整个仓库中唯一**,因为安装到客户端时不保留作者目录。重名时可加上作者前缀。 提交可直接运行的文件;如果需要构建,附上源码和构建说明。不要提交 `node_modules/`、密钥、真实会话记录、个人数据或运行缓存。截图和示例请使用合成数据或充分脱敏的数据。 提交前在 MiniMax Code 中手动安装、打开并检查主要功能,在 PR 中写明测试环境和结果;未验证的部分如实说明。更新已有作品时,保留插件 ID,并按改动更新版本与使用说明。 + +使用 AI 编码助手时,它会自动读取 `AGENTS.md`;`docs/` 里是同一套规则的人类可读版本。 diff --git a/README.md b/README.md index 27a958e..7e10867 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,9 @@ To update an app, close it and exit MiniMax Code, then replace its complete plug Tools, games, visualizations, and small experiments are all welcome. To share a MiniApp: -1. Fork the repository and add a complete, ready-to-run package under `plugins/<your-github-username>/<plugin-id>/`. +1. Fork the repository, copy `examples/hello-miniapp/` to `plugins/<your-github-username>/<plugin-id>/`, and build your app there. The package rules are in [`docs/`](docs/package-contract.md); AI coding agents read [`AGENTS.md`](AGENTS.md) automatically. 2. Include a README, a license, and any required runtime files. Document setup, data access, and what you have tested. -3. Add the app to the tables in both root READMEs and open a pull request. +3. Add the app to the tables in both root READMEs, run `npm run check`, and open a pull request. Use a lowercase GitHub username for the author directory. The plugin directory name must match `.minimax-plugin/plugin.json` → `name`, and **plugin IDs must be unique across the repository**, since the author directory is not part of the installed path. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c0c796..5effb74 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,9 +86,9 @@ git clone https://github.com/MiniMax-AI/MiniMax-Code-MiniApps.git 欢迎分享工具、游戏、可视化应用和小实验。提交作品只需: -1. Fork 仓库,在 `plugins/<你的-github-username>/<plugin-id>/` 下加入可直接运行的完整插件包。 +1. Fork 仓库,把 `examples/hello-miniapp/` 复制到 `plugins/<你的-github-username>/<plugin-id>/`,在此基础上开发。插件包规则见 [`docs/`](docs/package-contract.md)(英文);AI 编码助手会自动读取 [`AGENTS.md`](AGENTS.md)。 2. 附上 README、许可证和运行所需文件,说明配置方式、数据访问范围与验证情况。 -3. 在根目录的中英文 README 作品表格中增加一行,然后提交 Pull Request。 +3. 在根目录的中英文 README 作品表格中增加一行,运行 `npm run check`,然后提交 Pull Request。 作者目录使用小写 GitHub username。插件目录名必须与 `.minimax-plugin/plugin.json` 中的 `name` 一致,且**插件 ID 在整个仓库中唯一**,因为安装路径不包含作者目录。 From f9ab13e4c01a8269387c633acfbacb4d305efe95 Mon Sep 17 00:00:00 2001 From: dazhi <dazhi@minimaxi.com> Date: Wed, 23 Sep 2026 16:47:44 +0800 Subject: [PATCH 12/14] Harden community MiniApp package checks Assisted-by: codex reason:community-contract-validator --- docs/package-contract.md | 9 ++--- .../mcode-token-usage-board/README.md | 8 +++-- .../yanhy2000/mcode-usage-monitor/README.md | 8 +++-- scripts/lib/contract.mjs | 5 +-- scripts/lib/package.mjs | 4 ++- scripts/lib/paths.mjs | 6 ++++ scripts/lib/rules/layout.mjs | 7 +++- scripts/lib/rules/manifest.mjs | 34 +++++++++++++++---- scripts/lib/rules/miniapp.mjs | 16 ++++++--- scripts/lib/rules/node.mjs | 26 +++++++++----- test/manifest.test.mjs | 27 ++++++++++++++- test/miniapp.test.mjs | 15 +++++--- test/node.test.mjs | 23 ++++++++++++- test/package.test.mjs | 17 ++++++++++ test/paths.test.mjs | 7 ++++ 15 files changed, 174 insertions(+), 38 deletions(-) diff --git a/docs/package-contract.md b/docs/package-contract.md index ceab2cf..a744c5c 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -96,10 +96,11 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, inside one of `artifacts.node`. `runtime.lifecycle` is `on-demand` or omitted. - `surface.path` is the route the Node entry serves the page on. It is relative to the Host and must not contain an origin, query, fragment, or backslash. A missing leading `/` is added. -- `mcpEndpoints` is an array of `{ "server": string, "path": string }`. `server` matches - `^[a-zA-Z0-9_-]{1,128}$` and must name a server declared through `plugin.json.mcpServers`; MiniMax - Code checks that reference at install time. `server` and `path` are each unique. Use `[]` when - there are none. +- `mcpEndpoints` is an array of `{ "server": string, "path": string }`; include `[]` when there + are none. `server` matches `^[a-zA-Z0-9_-]{1,128}$` and must name a server declared inside one + of the `plugin.json.mcpServers` descriptor files. `npm run check` reads those descriptor files + and checks the reference when they are valid JSON; MiniMax Code repeats the check at install time. + `server` and `path` are each unique. - `hostConnectorAccess` is optional: `{ "providers": string[] }`, each matching `^[a-z0-9_-]{1,64}$` and unique. Declared providers are granted by the Host at install time; this repository does not document their use. diff --git a/plugins/amszuidas/mcode-token-usage-board/README.md b/plugins/amszuidas/mcode-token-usage-board/README.md index cdde0d9..f5c80b7 100644 --- a/plugins/amszuidas/mcode-token-usage-board/README.md +++ b/plugins/amszuidas/mcode-token-usage-board/README.md @@ -22,7 +22,7 @@ Include the hidden `.minimax-plugin` directory. Restart a version of MiniMax Cod Choose today, the past 7 days, the past 30 days, or all time, and optionally filter by model or session. Click “刷新” (Refresh) to update the data; the page also refreshes every 60 seconds. No API key or additional dependency installation is required. -## Data access and counting +## Data & access The service searches upward from the Host-provided `context.dataDir` for `v2/sessions`. If none is found, it falls back to `.minimax/v2/sessions` in your home directory. It reads `manifest.json` and `messages.jsonl` from session directories to extract usage, model names, session IDs, and titles derived from the first meaningful user text. @@ -30,11 +30,15 @@ Usage is grouped by day in your local time zone. The total adds input, output, c The runtime does not write local files or upload data to external services. It has no telemetry or credential configuration, and its cache stays in process memory. The page displays actual session titles, so take care when sharing screenshots or your screen. +## Tested environment + +The contribution was checked with synthetic sessions and a browser preview. Installation in the actual MiniMax Code desktop app, the minimum supported client version, and compatibility across operating systems have not been verified. + ## Source and verification The page is in `miniapp/client/index.html`, and the Node service is in `miniapp/node/server.mjs`. No build step is required. -Checks performed when adding this app covered usage aggregation, data refresh, error handling, and service shutdown with synthetic sessions, plus a browser preview. Installation in the actual MiniMax Code desktop app has not been verified as part of this contribution. The minimum supported client version and compatibility across operating systems remain unconfirmed. +Checks performed when adding this app covered usage aggregation, data refresh, error handling, and service shutdown with synthetic sessions, plus a browser preview. ## License diff --git a/plugins/yanhy2000/mcode-usage-monitor/README.md b/plugins/yanhy2000/mcode-usage-monitor/README.md index 4fa1286..d557768 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/README.md +++ b/plugins/yanhy2000/mcode-usage-monitor/README.md @@ -24,7 +24,7 @@ The page opens on the last 24 hours. You can switch between 1 hour, 24 hours, 7 This app requires **Python 3.8+** on the local machine to read the local database. It uses only the standard library, so no `pip install` is needed. No API key or other configuration is required. -## Data access and counting +## Data & access The app reads two local sources: @@ -40,11 +40,15 @@ This is a local, near-real-time view. The in-product usage page (Settings → Us The runtime sends nothing to external services and has no telemetry. It writes a single preferences file (`prefs.json`) into the Host-provided plugin data directory. The page shows real session titles, so take care when sharing screenshots or your screen. +## Tested environment + +Verified environment: MiniMax Code desktop `3.0.73.166` on Windows (10.0.26200, x64). macOS and Linux are unverified. + ## Source and verification The page is in `miniapp/client/index.html` (ECharts is bundled locally), the Node entry is `miniapp/node/server.mjs`, and the data backend is `miniapp/node/api.py`. No build step is required. -Verified environment: MiniMax Code desktop `3.0.73.166` on Windows (10.0.26200, x64). Verified during development: plugin install and open, aggregation and de-duplication, model/session filtering, preference persistence, auto refresh, theme switching, and chart and table rendering. macOS and Linux are unverified. +Verified during development: plugin install and open, aggregation and de-duplication, model/session filtering, preference persistence, auto refresh, theme switching, and chart and table rendering. Third-party components: [ECharts](https://echarts.apache.org/) (Apache License 2.0), bundled locally for offline use. diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs index 361c237..1b2ed50 100644 --- a/scripts/lib/contract.mjs +++ b/scripts/lib/contract.mjs @@ -103,5 +103,6 @@ export const REQUIRED_FILES = [ export const README_HEADINGS = ['## Tested environment', '## Data & access']; export const STDOUT_CALL = /\b(?:console\.(?:log|info|debug|dir|table)|process\.stdout\.write)\s*\(/u; -export const START_EXPORT = - /^\s*export\s+(?:async\s+function\s+start\b|function\s+start\b|const\s+start\b|let\s+start\b|\{[^}]*\bstart\b[^}]*\})/mu; +export const START_EXPORT_DECLARATION = + /^\s*export\s+(?:(?:async\s+)?function\s+start\b|(?:const|let|var)\s+start\b)/mu; +export const START_EXPORT_LIST = /^\s*export\s*\{([^}]*)\}/gmu; diff --git a/scripts/lib/package.mjs b/scripts/lib/package.mjs index d106262..f6e386a 100644 --- a/scripts/lib/package.mjs +++ b/scripts/lib/package.mjs @@ -13,12 +13,14 @@ import { checkReadme } from './rules/readme.mjs'; export async function validatePackage(packageDir, { requireLowercaseAuthor = true } = {}) { const dir = path.resolve(packageDir); const report = createReport(); + let mcpServerNames = []; await checkLayout(report, { packageDir: dir, requireLowercaseAuthor }); const manifest = await readJson(report, dir, '.minimax-plugin/plugin.json', 'MANIFEST_UNREADABLE'); if (manifest.present) { const identity = await checkManifest(report, { packageDir: dir, manifest: manifest.value }); + mcpServerNames = identity.mcpServerNames ?? []; await checkNameMatchesDirectory(report, { packageDir: dir, name: identity.name }); if (identity.icon) await checkImageFile(report, { packageDir: dir, relativePath: identity.icon, label: 'icon' }); if (identity.darkIcon) await checkImageFile(report, { packageDir: dir, relativePath: identity.darkIcon, label: 'darkIcon' }); @@ -29,7 +31,7 @@ export async function validatePackage(packageDir, { requireLowercaseAuthor = tru const miniapp = await readJson(report, dir, 'miniapp/miniapp.json', 'MINIAPP_UNREADABLE'); let resolved = { nodeRoots: [], clientRoots: [] }; - if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value }); + if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value, mcpServerNames }); await checkFiles(report, { packageDir: dir }); await checkReadme(report, { packageDir: dir }); diff --git a/scripts/lib/paths.mjs b/scripts/lib/paths.mjs index ccbf4d1..dec86cf 100644 --- a/scripts/lib/paths.mjs +++ b/scripts/lib/paths.mjs @@ -14,6 +14,12 @@ export function normalizePluginPath(value) { return { ok: true, value: normalized }; } +export function resolvePackagePath(packageDir, relativePath) { + const root = path.resolve(packageDir); + const absolute = path.resolve(root, ...relativePath.split('/')); + return absolute === root || absolute.startsWith(`${root}${path.sep}`) ? absolute : undefined; +} + export function portablePathIssue(relativePath) { if (!relativePath) return 'path is empty'; if (!/^[\x00-\x7f]*$/u.test(relativePath)) return 'is not ASCII'; diff --git a/scripts/lib/rules/layout.mjs b/scripts/lib/rules/layout.mjs index 642b52f..6c3b27c 100644 --- a/scripts/lib/rules/layout.mjs +++ b/scripts/lib/rules/layout.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { REQUIRED_FILES } from '../contract.mjs'; import { pathExists } from '../fs.mjs'; +import { resolvePackagePath } from '../paths.mjs'; export async function checkLayout(report, { packageDir, requireLowercaseAuthor }) { if (requireLowercaseAuthor) { @@ -36,7 +37,11 @@ const SIGNATURES = [ ]; export async function checkImageFile(report, { packageDir, relativePath, label }) { - const absolute = path.join(packageDir, ...relativePath.split('/')); + const absolute = resolvePackagePath(packageDir, relativePath); + if (absolute === undefined) { + report.error('LAYOUT_IMAGE_INVALID', `${label} path must stay inside the package`, relativePath); + return; + } if (!(await pathExists(absolute, 'file'))) { report.error('LAYOUT_IMAGE_INVALID', `${label} file does not exist`, relativePath); return; diff --git a/scripts/lib/rules/manifest.mjs b/scripts/lib/rules/manifest.mjs index 247fd9f..92f5cd7 100644 --- a/scripts/lib/rules/manifest.mjs +++ b/scripts/lib/rules/manifest.mjs @@ -1,5 +1,3 @@ -import path from 'node:path'; - import { APP_PATH, CATEGORIES, @@ -15,7 +13,8 @@ import { SKILL_PATH, VERSION_MAX_LENGTH, } from '../contract.mjs'; -import { isRecord, pathExists } from '../fs.mjs'; +import { isRecord, pathExists, readJsonFile } from '../fs.mjs'; +import { portablePathIssue, resolvePackagePath } from '../paths.mjs'; const FILE = '.minimax-plugin/plugin.json'; @@ -63,7 +62,8 @@ export async function checkManifest(report, { packageDir, manifest }) { if (apps && apps.length > 0) { report.warning('MANIFEST_APPS_IGNORED', 'apps entries are ignored for locally installed packages', FILE); } - await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true }); + const mcpServers = await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true }); + const mcpServerNames = await readMcpServerNames(packageDir, mcpServers); await references(report, packageDir, manifest.skills, 'skills', SKILL_PATH, { required: true, mustExist: true }); await references(report, packageDir, manifest.hooks, 'hooks', HOOK_PATH, { required: false, mustExist: true }); await references(report, packageDir, manifest.hostBindings, 'hostBindings', HOST_BINDING_PATH, { required: false, mustExist: true }); @@ -72,6 +72,7 @@ export async function checkManifest(report, { packageDir, manifest }) { if (name !== undefined) identity.name = name; if (icon !== undefined) identity.icon = icon; if (darkIcon !== undefined) identity.darkIcon = darkIcon; + identity.mcpServerNames = mcpServerNames; return identity; } @@ -103,7 +104,7 @@ function imagePath(report, value, label, required) { return undefined; } const trimmed = value.trim(); - if (trimmed.length > REFERENCE_MAX_LENGTH || !ICON_PATH.test(trimmed)) { + if (trimmed.length > REFERENCE_MAX_LENGTH || portablePathIssue(trimmed) || !ICON_PATH.test(trimmed)) { report.error('MANIFEST_FIELD_INVALID', `${label} must be a plugin-relative .png, .jpg, .jpeg, or .webp path`, FILE); return undefined; } @@ -130,13 +131,32 @@ async function references(report, packageDir, value, label, pattern, { required, return items; } for (const item of items) { - if (item.length > REFERENCE_MAX_LENGTH || !pattern.test(item)) { + const issue = portablePathIssue(item); + if (item.length > REFERENCE_MAX_LENGTH || issue || !pattern.test(item)) { report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must match ${pattern.source}`, FILE); continue; } - if (mustExist && !(await pathExists(path.join(packageDir, ...item.split('/')), 'file'))) { + const absolute = resolvePackagePath(packageDir, item); + if (absolute === undefined) { + report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must stay inside the package`, FILE); + continue; + } + if (mustExist && !(await pathExists(absolute, 'file'))) { report.error('MANIFEST_REFERENCE_MISSING', `${label} entry "${item}" does not exist`, item); } } return items; } + +async function readMcpServerNames(packageDir, references) { + if (!references) return []; + const names = new Set(); + for (const relativePath of references) { + const absolute = resolvePackagePath(packageDir, relativePath); + if (absolute === undefined) continue; + const result = await readJsonFile(absolute); + if (!result.ok || !isRecord(result.value) || !isRecord(result.value.mcpServers)) continue; + for (const name of Object.keys(result.value.mcpServers)) names.add(name); + } + return [...names]; +} diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index 6ad94f9..39a7e0a 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -18,7 +18,7 @@ import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.m const FILE = 'miniapp/miniapp.json'; -export async function checkMiniApp(report, { packageDir, manifest }) { +export async function checkMiniApp(report, { packageDir, manifest, mcpServerNames = [] }) { const resolved = { nodeRoots: [], clientRoots: [] }; if (!isRecord(manifest)) { report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE); @@ -35,7 +35,7 @@ export async function checkMiniApp(report, { packageDir, manifest }) { const entry = await checkRuntime(report, packageDir, manifest.runtime, artifacts.node); if (entry) resolved.entry = entry; checkSurface(report, manifest.surface); - checkMcpEndpoints(report, manifest.mcpEndpoints); + checkMcpEndpoints(report, manifest.mcpEndpoints, mcpServerNames); checkHostConnectorAccess(report, manifest.hostConnectorAccess); return resolved; } @@ -120,12 +120,16 @@ function checkSurface(report, value) { if (!route.ok) report.error('MINIAPP_SURFACE_INVALID', `surface.path ${route.reason}`, FILE); } -function checkMcpEndpoints(report, value) { - if (value === undefined) return; +function checkMcpEndpoints(report, value, mcpServerNames) { + if (value === undefined) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE); + return; + } if (!Array.isArray(value)) { report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE); return; } + const knownServers = new Set(mcpServerNames); const servers = new Set(); const paths = new Set(); for (const item of value) { @@ -137,6 +141,10 @@ function checkMcpEndpoints(report, value) { report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint server must match ${MCP_SERVER_NAME.source}`, FILE); continue; } + if (!knownServers.has(item.server)) { + report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP server "${item.server}" is not declared by plugin.json.mcpServers`, FILE); + continue; + } const route = normalizeRoutePath(item.path); if (!route.ok) { report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint path ${route.reason}`, FILE); diff --git a/scripts/lib/rules/node.mjs b/scripts/lib/rules/node.mjs index 8408f1f..f78a940 100644 --- a/scripts/lib/rules/node.mjs +++ b/scripts/lib/rules/node.mjs @@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; -import { START_EXPORT, STDOUT_CALL } from '../contract.mjs'; +import { START_EXPORT_DECLARATION, START_EXPORT_LIST, STDOUT_CALL } from '../contract.mjs'; import { pathExists } from '../fs.mjs'; const execFileAsync = promisify(execFile); @@ -25,13 +25,11 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) { const absolute = path.join(packageDir, ...relativePath.split('/')); if (!(await pathExists(absolute, 'file'))) continue; const isEntry = relativePath === entry; - if (path.extname(relativePath) !== '.js') { - try { - await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true }); - } catch (error) { - report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath); - continue; - } + try { + await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true }); + } catch (error) { + report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath); + continue; } const source = await readFile(absolute, 'utf8'); if (STDOUT_CALL.test(source)) { @@ -39,12 +37,22 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) { if (isEntry) report.error('ENTRY_STDOUT', message, relativePath); else report.warning('ENTRY_STDOUT', message, relativePath); } - if (isEntry && !START_EXPORT.test(source)) { + if (isEntry && !hasNamedStartExport(source)) { report.error('ENTRY_START_EXPORT_MISSING', 'the Node entry must export a named start function using ESM syntax', relativePath); } } } +function hasNamedStartExport(source) { + if (START_EXPORT_DECLARATION.test(source)) return true; + START_EXPORT_LIST.lastIndex = 0; + for (const match of source.matchAll(START_EXPORT_LIST)) { + const specifiers = match[1].split(',').map((specifier) => specifier.trim()); + if (specifiers.some((specifier) => /^(?:start|[A-Za-z_$][\w$]*\s+as\s+start|default\s+as\s+start)$/u.test(specifier))) return true; + } + return false; +} + async function collectScripts(absoluteDir, relativeDir) { const files = []; for (const dirent of await readdir(absoluteDir, { withFileTypes: true })) { diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index f517eeb..1347023 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -21,7 +21,7 @@ async function run(mutate, prepare) { test('manifest: baseline passes and returns identity', async () => { const { report, identity } = await run((m) => m); assert.deepEqual(report.diagnostics, []); - assert.deepEqual(identity, { name: 'hello-miniapp', icon: 'icon.png' }); + assert.deepEqual(identity, { name: 'hello-miniapp', icon: 'icon.png', mcpServerNames: [] }); }); test('manifest: $schema is an accepted optional string', async () => { @@ -82,6 +82,31 @@ test('manifest: skills, mcpServers, hooks, hostBindings need matching pattern an assert.deepEqual(codes(duplicate.report.diagnostics), ['MANIFEST_REFERENCE_INVALID']); }); +test('manifest: all declared paths reject dot segments before resolving files', async () => { + for (const [key, value] of [ + ['icon', '../icon.png'], + ['apps', ['nested/../x.app.json']], + ['mcpServers', ['./tools.mcp.json']], + ['skills', ['skills/../demo/SKILL.md']], + ['hooks', ['hooks/../hook.json']], + ['hostBindings', ['bindings/../x.binding.json']], + ]) { + const { report } = await run((m) => ({ ...m, [key]: value })); + assert.deepEqual(codes(report.diagnostics, 'error'), [key === 'icon' ? 'MANIFEST_FIELD_INVALID' : 'MANIFEST_REFERENCE_INVALID'], key); + } +}); + +test('manifest: MCP descriptor names are returned for MiniApp endpoint validation', async () => { + const { report, identity } = await run( + (m) => ({ ...m, mcpServers: ['servers.mcp.json'] }), + async (dir) => { + await writeFile(path.join(dir, 'servers.mcp.json'), JSON.stringify({ mcpServers: { local: {}, remote: {} } })); + }, + ); + assert.deepEqual(report.diagnostics, []); + assert.deepEqual(identity.mcpServerNames, ['local', 'remote']); +}); + test('manifest: non-object is a single error', async () => { const { report } = await run(() => null); assert.deepEqual(codes(report.diagnostics), ['MANIFEST_NOT_OBJECT']); diff --git a/test/miniapp.test.mjs b/test/miniapp.test.mjs index 86ce27f..0efaad3 100644 --- a/test/miniapp.test.mjs +++ b/test/miniapp.test.mjs @@ -6,7 +6,7 @@ import { checkMiniApp } from '../scripts/lib/rules/miniapp.mjs'; import { createReport } from '../scripts/lib/report.mjs'; import { codes, copyExample, makeTmpRoot, readJson } from './helpers.mjs'; -async function run(mutate, prepare) { +async function run(mutate, prepare, { mcpServerNames = [] } = {}) { const root = await makeTmpRoot(); const { dir } = await copyExample(root); const manifest = await readJson(path.join(dir, 'miniapp', 'miniapp.json')); @@ -14,7 +14,7 @@ async function run(mutate, prepare) { const next = result === undefined ? manifest : result; if (prepare) await prepare(dir); const report = createReport(); - const resolved = await checkMiniApp(report, { packageDir: dir, manifest: next }); + const resolved = await checkMiniApp(report, { packageDir: dir, manifest: next, mcpServerNames }); return { report, resolved }; } @@ -66,12 +66,19 @@ test('miniapp: surface.path accepts a missing leading slash and rejects transpor }); test('miniapp: mcpEndpoints shape and uniqueness', async () => { - const ok = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }]; return m; }); + const ok = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }]; return m; }, undefined, { mcpServerNames: ['a', 'b'] }); assert.deepEqual(ok.report.diagnostics, []); - const dupPath = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }, { server: 'b', path: '/mcp' }]; return m; }); + const dupPath = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }, { server: 'b', path: '/mcp' }]; return m; }, undefined, { mcpServerNames: ['a', 'b'] }); assert.deepEqual(codes(dupPath.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); const badServer = await run((m) => { m.mcpEndpoints = [{ server: 'has space', path: '/mcp' }]; return m; }); assert.deepEqual(codes(badServer.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); + const undeclared = await run((m) => { m.mcpEndpoints = [{ server: 'missing', path: '/mcp' }]; return m; }, undefined, { mcpServerNames: ['a'] }); + assert.deepEqual(codes(undeclared.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); +}); + +test('miniapp: mcpEndpoints is required even when empty', async () => { + const missing = await run((m) => { delete m.mcpEndpoints; return m; }); + assert.deepEqual(codes(missing.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); }); test('miniapp: hostConnectorAccess is validated for shape and reported as unverified', async () => { diff --git a/test/node.test.mjs b/test/node.test.mjs index acab22a..b26a66e 100644 --- a/test/node.test.mjs +++ b/test/node.test.mjs @@ -39,6 +39,11 @@ test('node: entry must export start with ESM syntax', async () => { await writeFile(file, (await readFile(file, 'utf8')).replace('export async function start', 'async function start')); }); assert.deepEqual(codes(diagnostics), ['ENTRY_START_EXPORT_MISSING']); + const aliased = await run(async (dir) => { + const file = path.join(dir, ENTRY); + await writeFile(file, (await readFile(file, 'utf8')).replace('export async function start', 'async function start').concat('\nexport { start as foo };\n')); + }); + assert.deepEqual(codes(aliased), ['ENTRY_START_EXPORT_MISSING']); }); test('node: a syntax error in an .mjs file is an error', async () => { @@ -46,9 +51,25 @@ test('node: a syntax error in an .mjs file is an error', async () => { assert.deepEqual(codes(diagnostics), ['ENTRY_SYNTAX']); }); -test('node: .js files skip the syntax check and rely on the pattern checks only', async () => { +test('node: .js files are syntax checked', async () => { const diagnostics = await run(async (dir) => { await writeFile(path.join(dir, 'miniapp', 'node', 'helper.js'), 'module.exports = { broken: ( };\n'); }); + assert.deepEqual(codes(diagnostics), ['ENTRY_SYNTAX']); +}); + +test('node: entry export list accepts the exact named start binding', async () => { + const diagnostics = await run(async (dir) => { + const file = path.join(dir, ENTRY); + await writeFile(file, 'async function start() {}\nexport { start };\n'); + }); + assert.deepEqual(diagnostics, []); +}); + +test('node: an export alias whose exported name is start is accepted', async () => { + const diagnostics = await run(async (dir) => { + const file = path.join(dir, ENTRY); + await writeFile(file, 'async function run() {}\nexport { run as start };\n'); + }); assert.deepEqual(diagnostics, []); }); diff --git a/test/package.test.mjs b/test/package.test.mjs index ad25cb0..c2dc998 100644 --- a/test/package.test.mjs +++ b/test/package.test.mjs @@ -46,6 +46,23 @@ test('package: warnings alone do not make hasErrors true', async () => { assert.equal(hasErrors(diagnostics), false); }); +test('package: MiniApp MCP endpoints are checked against declared server names', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + const pluginManifest = JSON.parse(await readFile(path.join(dir, '.minimax-plugin', 'plugin.json'), 'utf8')); + pluginManifest.mcpServers = ['servers.mcp.json']; + await writeFile(path.join(dir, '.minimax-plugin', 'plugin.json'), `${JSON.stringify(pluginManifest)}\n`); + await writeFile(path.join(dir, 'servers.mcp.json'), JSON.stringify({ mcpServers: { local: {} } })); + const miniappManifest = JSON.parse(await readFile(path.join(dir, 'miniapp', 'miniapp.json'), 'utf8')); + miniappManifest.mcpEndpoints = [{ server: 'local', path: '/mcp' }]; + await writeFile(path.join(dir, 'miniapp', 'miniapp.json'), `${JSON.stringify(miniappManifest)}\n`); + assert.deepEqual(await validatePackage(dir), []); + + miniappManifest.mcpEndpoints = [{ server: 'missing', path: '/mcp' }]; + await writeFile(path.join(dir, 'miniapp', 'miniapp.json'), `${JSON.stringify(miniappManifest)}\n`); + assert.deepEqual(codes(await validatePackage(dir)), ['MINIAPP_MCP_ENDPOINT_INVALID']); +}); + test('package: an example is not subject to the lowercase-author rule', async () => { const root = await makeTmpRoot(); const { dir } = await copyExample(root, { author: 'Examples' }); diff --git a/test/paths.test.mjs b/test/paths.test.mjs index 8860501..2a3d9fc 100644 --- a/test/paths.test.mjs +++ b/test/paths.test.mjs @@ -5,6 +5,7 @@ import { normalizePluginPath, normalizeRoutePath, portablePathIssue, + resolvePackagePath, } from '../scripts/lib/paths.mjs'; test('normalizePluginPath strips ./ and rejects non-canonical input', () => { @@ -27,6 +28,12 @@ test('portablePathIssue mirrors the Host rules', () => { assert.match(portablePathIssue(Array.from({ length: 17 }, () => 'a').join('/')), /too many segments/); }); +test('resolvePackagePath never resolves outside the package root', () => { + assert.equal(resolvePackagePath('/tmp/package', 'docs/readme.md'), '/tmp/package/docs/readme.md'); + assert.equal(resolvePackagePath('/tmp/package', '../outside.txt'), undefined); + assert.equal(resolvePackagePath('/tmp/package', 'nested/../../outside.txt'), undefined); +}); + test('normalizeRoutePath adds the leading slash and rejects transport syntax', () => { assert.deepEqual(normalizeRoutePath('dashboard'), { ok: true, value: '/dashboard' }); assert.deepEqual(normalizeRoutePath('/dashboard'), { ok: true, value: '/dashboard' }); From 375589516be980cfa67633353be519deb2f1f407 Mon Sep 17 00:00:00 2001 From: dazhi <dazhi@minimaxi.com> Date: Wed, 23 Sep 2026 17:28:20 +0800 Subject: [PATCH 13/14] Revert "Harden community MiniApp package checks" This reverts commit f9ab13e4c01a8269387c633acfbacb4d305efe95. Three of its changes go beyond the reviewed contract: mcpEndpoints becomes required, *.mcp.json descriptors are parsed for a cross-check, and .js entries run node --check. It also edited two community plugin READMEs only to silence warnings. The path-containment and start-export improvements return in a separate pull request. --- docs/package-contract.md | 9 +++-- .../mcode-token-usage-board/README.md | 8 ++--- .../yanhy2000/mcode-usage-monitor/README.md | 8 ++--- scripts/lib/contract.mjs | 5 ++- scripts/lib/package.mjs | 4 +-- scripts/lib/paths.mjs | 6 ---- scripts/lib/rules/layout.mjs | 7 +--- scripts/lib/rules/manifest.mjs | 34 ++++--------------- scripts/lib/rules/miniapp.mjs | 16 +++------ scripts/lib/rules/node.mjs | 26 +++++--------- test/manifest.test.mjs | 27 +-------------- test/miniapp.test.mjs | 15 +++----- test/node.test.mjs | 23 +------------ test/package.test.mjs | 17 ---------- test/paths.test.mjs | 7 ---- 15 files changed, 38 insertions(+), 174 deletions(-) diff --git a/docs/package-contract.md b/docs/package-contract.md index a744c5c..ceab2cf 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -96,11 +96,10 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, inside one of `artifacts.node`. `runtime.lifecycle` is `on-demand` or omitted. - `surface.path` is the route the Node entry serves the page on. It is relative to the Host and must not contain an origin, query, fragment, or backslash. A missing leading `/` is added. -- `mcpEndpoints` is an array of `{ "server": string, "path": string }`; include `[]` when there - are none. `server` matches `^[a-zA-Z0-9_-]{1,128}$` and must name a server declared inside one - of the `plugin.json.mcpServers` descriptor files. `npm run check` reads those descriptor files - and checks the reference when they are valid JSON; MiniMax Code repeats the check at install time. - `server` and `path` are each unique. +- `mcpEndpoints` is an array of `{ "server": string, "path": string }`. `server` matches + `^[a-zA-Z0-9_-]{1,128}$` and must name a server declared through `plugin.json.mcpServers`; MiniMax + Code checks that reference at install time. `server` and `path` are each unique. Use `[]` when + there are none. - `hostConnectorAccess` is optional: `{ "providers": string[] }`, each matching `^[a-z0-9_-]{1,64}$` and unique. Declared providers are granted by the Host at install time; this repository does not document their use. diff --git a/plugins/amszuidas/mcode-token-usage-board/README.md b/plugins/amszuidas/mcode-token-usage-board/README.md index f5c80b7..cdde0d9 100644 --- a/plugins/amszuidas/mcode-token-usage-board/README.md +++ b/plugins/amszuidas/mcode-token-usage-board/README.md @@ -22,7 +22,7 @@ Include the hidden `.minimax-plugin` directory. Restart a version of MiniMax Cod Choose today, the past 7 days, the past 30 days, or all time, and optionally filter by model or session. Click “刷新” (Refresh) to update the data; the page also refreshes every 60 seconds. No API key or additional dependency installation is required. -## Data & access +## Data access and counting The service searches upward from the Host-provided `context.dataDir` for `v2/sessions`. If none is found, it falls back to `.minimax/v2/sessions` in your home directory. It reads `manifest.json` and `messages.jsonl` from session directories to extract usage, model names, session IDs, and titles derived from the first meaningful user text. @@ -30,15 +30,11 @@ Usage is grouped by day in your local time zone. The total adds input, output, c The runtime does not write local files or upload data to external services. It has no telemetry or credential configuration, and its cache stays in process memory. The page displays actual session titles, so take care when sharing screenshots or your screen. -## Tested environment - -The contribution was checked with synthetic sessions and a browser preview. Installation in the actual MiniMax Code desktop app, the minimum supported client version, and compatibility across operating systems have not been verified. - ## Source and verification The page is in `miniapp/client/index.html`, and the Node service is in `miniapp/node/server.mjs`. No build step is required. -Checks performed when adding this app covered usage aggregation, data refresh, error handling, and service shutdown with synthetic sessions, plus a browser preview. +Checks performed when adding this app covered usage aggregation, data refresh, error handling, and service shutdown with synthetic sessions, plus a browser preview. Installation in the actual MiniMax Code desktop app has not been verified as part of this contribution. The minimum supported client version and compatibility across operating systems remain unconfirmed. ## License diff --git a/plugins/yanhy2000/mcode-usage-monitor/README.md b/plugins/yanhy2000/mcode-usage-monitor/README.md index d557768..4fa1286 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/README.md +++ b/plugins/yanhy2000/mcode-usage-monitor/README.md @@ -24,7 +24,7 @@ The page opens on the last 24 hours. You can switch between 1 hour, 24 hours, 7 This app requires **Python 3.8+** on the local machine to read the local database. It uses only the standard library, so no `pip install` is needed. No API key or other configuration is required. -## Data & access +## Data access and counting The app reads two local sources: @@ -40,15 +40,11 @@ This is a local, near-real-time view. The in-product usage page (Settings → Us The runtime sends nothing to external services and has no telemetry. It writes a single preferences file (`prefs.json`) into the Host-provided plugin data directory. The page shows real session titles, so take care when sharing screenshots or your screen. -## Tested environment - -Verified environment: MiniMax Code desktop `3.0.73.166` on Windows (10.0.26200, x64). macOS and Linux are unverified. - ## Source and verification The page is in `miniapp/client/index.html` (ECharts is bundled locally), the Node entry is `miniapp/node/server.mjs`, and the data backend is `miniapp/node/api.py`. No build step is required. -Verified during development: plugin install and open, aggregation and de-duplication, model/session filtering, preference persistence, auto refresh, theme switching, and chart and table rendering. +Verified environment: MiniMax Code desktop `3.0.73.166` on Windows (10.0.26200, x64). Verified during development: plugin install and open, aggregation and de-duplication, model/session filtering, preference persistence, auto refresh, theme switching, and chart and table rendering. macOS and Linux are unverified. Third-party components: [ECharts](https://echarts.apache.org/) (Apache License 2.0), bundled locally for offline use. diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs index 1b2ed50..361c237 100644 --- a/scripts/lib/contract.mjs +++ b/scripts/lib/contract.mjs @@ -103,6 +103,5 @@ export const REQUIRED_FILES = [ export const README_HEADINGS = ['## Tested environment', '## Data & access']; export const STDOUT_CALL = /\b(?:console\.(?:log|info|debug|dir|table)|process\.stdout\.write)\s*\(/u; -export const START_EXPORT_DECLARATION = - /^\s*export\s+(?:(?:async\s+)?function\s+start\b|(?:const|let|var)\s+start\b)/mu; -export const START_EXPORT_LIST = /^\s*export\s*\{([^}]*)\}/gmu; +export const START_EXPORT = + /^\s*export\s+(?:async\s+function\s+start\b|function\s+start\b|const\s+start\b|let\s+start\b|\{[^}]*\bstart\b[^}]*\})/mu; diff --git a/scripts/lib/package.mjs b/scripts/lib/package.mjs index f6e386a..d106262 100644 --- a/scripts/lib/package.mjs +++ b/scripts/lib/package.mjs @@ -13,14 +13,12 @@ import { checkReadme } from './rules/readme.mjs'; export async function validatePackage(packageDir, { requireLowercaseAuthor = true } = {}) { const dir = path.resolve(packageDir); const report = createReport(); - let mcpServerNames = []; await checkLayout(report, { packageDir: dir, requireLowercaseAuthor }); const manifest = await readJson(report, dir, '.minimax-plugin/plugin.json', 'MANIFEST_UNREADABLE'); if (manifest.present) { const identity = await checkManifest(report, { packageDir: dir, manifest: manifest.value }); - mcpServerNames = identity.mcpServerNames ?? []; await checkNameMatchesDirectory(report, { packageDir: dir, name: identity.name }); if (identity.icon) await checkImageFile(report, { packageDir: dir, relativePath: identity.icon, label: 'icon' }); if (identity.darkIcon) await checkImageFile(report, { packageDir: dir, relativePath: identity.darkIcon, label: 'darkIcon' }); @@ -31,7 +29,7 @@ export async function validatePackage(packageDir, { requireLowercaseAuthor = tru const miniapp = await readJson(report, dir, 'miniapp/miniapp.json', 'MINIAPP_UNREADABLE'); let resolved = { nodeRoots: [], clientRoots: [] }; - if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value, mcpServerNames }); + if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value }); await checkFiles(report, { packageDir: dir }); await checkReadme(report, { packageDir: dir }); diff --git a/scripts/lib/paths.mjs b/scripts/lib/paths.mjs index dec86cf..ccbf4d1 100644 --- a/scripts/lib/paths.mjs +++ b/scripts/lib/paths.mjs @@ -14,12 +14,6 @@ export function normalizePluginPath(value) { return { ok: true, value: normalized }; } -export function resolvePackagePath(packageDir, relativePath) { - const root = path.resolve(packageDir); - const absolute = path.resolve(root, ...relativePath.split('/')); - return absolute === root || absolute.startsWith(`${root}${path.sep}`) ? absolute : undefined; -} - export function portablePathIssue(relativePath) { if (!relativePath) return 'path is empty'; if (!/^[\x00-\x7f]*$/u.test(relativePath)) return 'is not ASCII'; diff --git a/scripts/lib/rules/layout.mjs b/scripts/lib/rules/layout.mjs index 6c3b27c..642b52f 100644 --- a/scripts/lib/rules/layout.mjs +++ b/scripts/lib/rules/layout.mjs @@ -3,7 +3,6 @@ import path from 'node:path'; import { REQUIRED_FILES } from '../contract.mjs'; import { pathExists } from '../fs.mjs'; -import { resolvePackagePath } from '../paths.mjs'; export async function checkLayout(report, { packageDir, requireLowercaseAuthor }) { if (requireLowercaseAuthor) { @@ -37,11 +36,7 @@ const SIGNATURES = [ ]; export async function checkImageFile(report, { packageDir, relativePath, label }) { - const absolute = resolvePackagePath(packageDir, relativePath); - if (absolute === undefined) { - report.error('LAYOUT_IMAGE_INVALID', `${label} path must stay inside the package`, relativePath); - return; - } + const absolute = path.join(packageDir, ...relativePath.split('/')); if (!(await pathExists(absolute, 'file'))) { report.error('LAYOUT_IMAGE_INVALID', `${label} file does not exist`, relativePath); return; diff --git a/scripts/lib/rules/manifest.mjs b/scripts/lib/rules/manifest.mjs index 92f5cd7..247fd9f 100644 --- a/scripts/lib/rules/manifest.mjs +++ b/scripts/lib/rules/manifest.mjs @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { APP_PATH, CATEGORIES, @@ -13,8 +15,7 @@ import { SKILL_PATH, VERSION_MAX_LENGTH, } from '../contract.mjs'; -import { isRecord, pathExists, readJsonFile } from '../fs.mjs'; -import { portablePathIssue, resolvePackagePath } from '../paths.mjs'; +import { isRecord, pathExists } from '../fs.mjs'; const FILE = '.minimax-plugin/plugin.json'; @@ -62,8 +63,7 @@ export async function checkManifest(report, { packageDir, manifest }) { if (apps && apps.length > 0) { report.warning('MANIFEST_APPS_IGNORED', 'apps entries are ignored for locally installed packages', FILE); } - const mcpServers = await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true }); - const mcpServerNames = await readMcpServerNames(packageDir, mcpServers); + await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true }); await references(report, packageDir, manifest.skills, 'skills', SKILL_PATH, { required: true, mustExist: true }); await references(report, packageDir, manifest.hooks, 'hooks', HOOK_PATH, { required: false, mustExist: true }); await references(report, packageDir, manifest.hostBindings, 'hostBindings', HOST_BINDING_PATH, { required: false, mustExist: true }); @@ -72,7 +72,6 @@ export async function checkManifest(report, { packageDir, manifest }) { if (name !== undefined) identity.name = name; if (icon !== undefined) identity.icon = icon; if (darkIcon !== undefined) identity.darkIcon = darkIcon; - identity.mcpServerNames = mcpServerNames; return identity; } @@ -104,7 +103,7 @@ function imagePath(report, value, label, required) { return undefined; } const trimmed = value.trim(); - if (trimmed.length > REFERENCE_MAX_LENGTH || portablePathIssue(trimmed) || !ICON_PATH.test(trimmed)) { + if (trimmed.length > REFERENCE_MAX_LENGTH || !ICON_PATH.test(trimmed)) { report.error('MANIFEST_FIELD_INVALID', `${label} must be a plugin-relative .png, .jpg, .jpeg, or .webp path`, FILE); return undefined; } @@ -131,32 +130,13 @@ async function references(report, packageDir, value, label, pattern, { required, return items; } for (const item of items) { - const issue = portablePathIssue(item); - if (item.length > REFERENCE_MAX_LENGTH || issue || !pattern.test(item)) { + if (item.length > REFERENCE_MAX_LENGTH || !pattern.test(item)) { report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must match ${pattern.source}`, FILE); continue; } - const absolute = resolvePackagePath(packageDir, item); - if (absolute === undefined) { - report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must stay inside the package`, FILE); - continue; - } - if (mustExist && !(await pathExists(absolute, 'file'))) { + if (mustExist && !(await pathExists(path.join(packageDir, ...item.split('/')), 'file'))) { report.error('MANIFEST_REFERENCE_MISSING', `${label} entry "${item}" does not exist`, item); } } return items; } - -async function readMcpServerNames(packageDir, references) { - if (!references) return []; - const names = new Set(); - for (const relativePath of references) { - const absolute = resolvePackagePath(packageDir, relativePath); - if (absolute === undefined) continue; - const result = await readJsonFile(absolute); - if (!result.ok || !isRecord(result.value) || !isRecord(result.value.mcpServers)) continue; - for (const name of Object.keys(result.value.mcpServers)) names.add(name); - } - return [...names]; -} diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index 39a7e0a..6ad94f9 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -18,7 +18,7 @@ import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.m const FILE = 'miniapp/miniapp.json'; -export async function checkMiniApp(report, { packageDir, manifest, mcpServerNames = [] }) { +export async function checkMiniApp(report, { packageDir, manifest }) { const resolved = { nodeRoots: [], clientRoots: [] }; if (!isRecord(manifest)) { report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE); @@ -35,7 +35,7 @@ export async function checkMiniApp(report, { packageDir, manifest, mcpServerName const entry = await checkRuntime(report, packageDir, manifest.runtime, artifacts.node); if (entry) resolved.entry = entry; checkSurface(report, manifest.surface); - checkMcpEndpoints(report, manifest.mcpEndpoints, mcpServerNames); + checkMcpEndpoints(report, manifest.mcpEndpoints); checkHostConnectorAccess(report, manifest.hostConnectorAccess); return resolved; } @@ -120,16 +120,12 @@ function checkSurface(report, value) { if (!route.ok) report.error('MINIAPP_SURFACE_INVALID', `surface.path ${route.reason}`, FILE); } -function checkMcpEndpoints(report, value, mcpServerNames) { - if (value === undefined) { - report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE); - return; - } +function checkMcpEndpoints(report, value) { + if (value === undefined) return; if (!Array.isArray(value)) { report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE); return; } - const knownServers = new Set(mcpServerNames); const servers = new Set(); const paths = new Set(); for (const item of value) { @@ -141,10 +137,6 @@ function checkMcpEndpoints(report, value, mcpServerNames) { report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint server must match ${MCP_SERVER_NAME.source}`, FILE); continue; } - if (!knownServers.has(item.server)) { - report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP server "${item.server}" is not declared by plugin.json.mcpServers`, FILE); - continue; - } const route = normalizeRoutePath(item.path); if (!route.ok) { report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint path ${route.reason}`, FILE); diff --git a/scripts/lib/rules/node.mjs b/scripts/lib/rules/node.mjs index f78a940..8408f1f 100644 --- a/scripts/lib/rules/node.mjs +++ b/scripts/lib/rules/node.mjs @@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; -import { START_EXPORT_DECLARATION, START_EXPORT_LIST, STDOUT_CALL } from '../contract.mjs'; +import { START_EXPORT, STDOUT_CALL } from '../contract.mjs'; import { pathExists } from '../fs.mjs'; const execFileAsync = promisify(execFile); @@ -25,11 +25,13 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) { const absolute = path.join(packageDir, ...relativePath.split('/')); if (!(await pathExists(absolute, 'file'))) continue; const isEntry = relativePath === entry; - try { - await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true }); - } catch (error) { - report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath); - continue; + if (path.extname(relativePath) !== '.js') { + try { + await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true }); + } catch (error) { + report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath); + continue; + } } const source = await readFile(absolute, 'utf8'); if (STDOUT_CALL.test(source)) { @@ -37,22 +39,12 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) { if (isEntry) report.error('ENTRY_STDOUT', message, relativePath); else report.warning('ENTRY_STDOUT', message, relativePath); } - if (isEntry && !hasNamedStartExport(source)) { + if (isEntry && !START_EXPORT.test(source)) { report.error('ENTRY_START_EXPORT_MISSING', 'the Node entry must export a named start function using ESM syntax', relativePath); } } } -function hasNamedStartExport(source) { - if (START_EXPORT_DECLARATION.test(source)) return true; - START_EXPORT_LIST.lastIndex = 0; - for (const match of source.matchAll(START_EXPORT_LIST)) { - const specifiers = match[1].split(',').map((specifier) => specifier.trim()); - if (specifiers.some((specifier) => /^(?:start|[A-Za-z_$][\w$]*\s+as\s+start|default\s+as\s+start)$/u.test(specifier))) return true; - } - return false; -} - async function collectScripts(absoluteDir, relativeDir) { const files = []; for (const dirent of await readdir(absoluteDir, { withFileTypes: true })) { diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index 1347023..f517eeb 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -21,7 +21,7 @@ async function run(mutate, prepare) { test('manifest: baseline passes and returns identity', async () => { const { report, identity } = await run((m) => m); assert.deepEqual(report.diagnostics, []); - assert.deepEqual(identity, { name: 'hello-miniapp', icon: 'icon.png', mcpServerNames: [] }); + assert.deepEqual(identity, { name: 'hello-miniapp', icon: 'icon.png' }); }); test('manifest: $schema is an accepted optional string', async () => { @@ -82,31 +82,6 @@ test('manifest: skills, mcpServers, hooks, hostBindings need matching pattern an assert.deepEqual(codes(duplicate.report.diagnostics), ['MANIFEST_REFERENCE_INVALID']); }); -test('manifest: all declared paths reject dot segments before resolving files', async () => { - for (const [key, value] of [ - ['icon', '../icon.png'], - ['apps', ['nested/../x.app.json']], - ['mcpServers', ['./tools.mcp.json']], - ['skills', ['skills/../demo/SKILL.md']], - ['hooks', ['hooks/../hook.json']], - ['hostBindings', ['bindings/../x.binding.json']], - ]) { - const { report } = await run((m) => ({ ...m, [key]: value })); - assert.deepEqual(codes(report.diagnostics, 'error'), [key === 'icon' ? 'MANIFEST_FIELD_INVALID' : 'MANIFEST_REFERENCE_INVALID'], key); - } -}); - -test('manifest: MCP descriptor names are returned for MiniApp endpoint validation', async () => { - const { report, identity } = await run( - (m) => ({ ...m, mcpServers: ['servers.mcp.json'] }), - async (dir) => { - await writeFile(path.join(dir, 'servers.mcp.json'), JSON.stringify({ mcpServers: { local: {}, remote: {} } })); - }, - ); - assert.deepEqual(report.diagnostics, []); - assert.deepEqual(identity.mcpServerNames, ['local', 'remote']); -}); - test('manifest: non-object is a single error', async () => { const { report } = await run(() => null); assert.deepEqual(codes(report.diagnostics), ['MANIFEST_NOT_OBJECT']); diff --git a/test/miniapp.test.mjs b/test/miniapp.test.mjs index 0efaad3..86ce27f 100644 --- a/test/miniapp.test.mjs +++ b/test/miniapp.test.mjs @@ -6,7 +6,7 @@ import { checkMiniApp } from '../scripts/lib/rules/miniapp.mjs'; import { createReport } from '../scripts/lib/report.mjs'; import { codes, copyExample, makeTmpRoot, readJson } from './helpers.mjs'; -async function run(mutate, prepare, { mcpServerNames = [] } = {}) { +async function run(mutate, prepare) { const root = await makeTmpRoot(); const { dir } = await copyExample(root); const manifest = await readJson(path.join(dir, 'miniapp', 'miniapp.json')); @@ -14,7 +14,7 @@ async function run(mutate, prepare, { mcpServerNames = [] } = {}) { const next = result === undefined ? manifest : result; if (prepare) await prepare(dir); const report = createReport(); - const resolved = await checkMiniApp(report, { packageDir: dir, manifest: next, mcpServerNames }); + const resolved = await checkMiniApp(report, { packageDir: dir, manifest: next }); return { report, resolved }; } @@ -66,19 +66,12 @@ test('miniapp: surface.path accepts a missing leading slash and rejects transpor }); test('miniapp: mcpEndpoints shape and uniqueness', async () => { - const ok = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }]; return m; }, undefined, { mcpServerNames: ['a', 'b'] }); + const ok = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }]; return m; }); assert.deepEqual(ok.report.diagnostics, []); - const dupPath = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }, { server: 'b', path: '/mcp' }]; return m; }, undefined, { mcpServerNames: ['a', 'b'] }); + const dupPath = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/mcp' }, { server: 'b', path: '/mcp' }]; return m; }); assert.deepEqual(codes(dupPath.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); const badServer = await run((m) => { m.mcpEndpoints = [{ server: 'has space', path: '/mcp' }]; return m; }); assert.deepEqual(codes(badServer.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); - const undeclared = await run((m) => { m.mcpEndpoints = [{ server: 'missing', path: '/mcp' }]; return m; }, undefined, { mcpServerNames: ['a'] }); - assert.deepEqual(codes(undeclared.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); -}); - -test('miniapp: mcpEndpoints is required even when empty', async () => { - const missing = await run((m) => { delete m.mcpEndpoints; return m; }); - assert.deepEqual(codes(missing.report.diagnostics), ['MINIAPP_MCP_ENDPOINT_INVALID']); }); test('miniapp: hostConnectorAccess is validated for shape and reported as unverified', async () => { diff --git a/test/node.test.mjs b/test/node.test.mjs index b26a66e..acab22a 100644 --- a/test/node.test.mjs +++ b/test/node.test.mjs @@ -39,11 +39,6 @@ test('node: entry must export start with ESM syntax', async () => { await writeFile(file, (await readFile(file, 'utf8')).replace('export async function start', 'async function start')); }); assert.deepEqual(codes(diagnostics), ['ENTRY_START_EXPORT_MISSING']); - const aliased = await run(async (dir) => { - const file = path.join(dir, ENTRY); - await writeFile(file, (await readFile(file, 'utf8')).replace('export async function start', 'async function start').concat('\nexport { start as foo };\n')); - }); - assert.deepEqual(codes(aliased), ['ENTRY_START_EXPORT_MISSING']); }); test('node: a syntax error in an .mjs file is an error', async () => { @@ -51,25 +46,9 @@ test('node: a syntax error in an .mjs file is an error', async () => { assert.deepEqual(codes(diagnostics), ['ENTRY_SYNTAX']); }); -test('node: .js files are syntax checked', async () => { +test('node: .js files skip the syntax check and rely on the pattern checks only', async () => { const diagnostics = await run(async (dir) => { await writeFile(path.join(dir, 'miniapp', 'node', 'helper.js'), 'module.exports = { broken: ( };\n'); }); - assert.deepEqual(codes(diagnostics), ['ENTRY_SYNTAX']); -}); - -test('node: entry export list accepts the exact named start binding', async () => { - const diagnostics = await run(async (dir) => { - const file = path.join(dir, ENTRY); - await writeFile(file, 'async function start() {}\nexport { start };\n'); - }); - assert.deepEqual(diagnostics, []); -}); - -test('node: an export alias whose exported name is start is accepted', async () => { - const diagnostics = await run(async (dir) => { - const file = path.join(dir, ENTRY); - await writeFile(file, 'async function run() {}\nexport { run as start };\n'); - }); assert.deepEqual(diagnostics, []); }); diff --git a/test/package.test.mjs b/test/package.test.mjs index c2dc998..ad25cb0 100644 --- a/test/package.test.mjs +++ b/test/package.test.mjs @@ -46,23 +46,6 @@ test('package: warnings alone do not make hasErrors true', async () => { assert.equal(hasErrors(diagnostics), false); }); -test('package: MiniApp MCP endpoints are checked against declared server names', async () => { - const root = await makeTmpRoot(); - const { dir } = await copyExample(root); - const pluginManifest = JSON.parse(await readFile(path.join(dir, '.minimax-plugin', 'plugin.json'), 'utf8')); - pluginManifest.mcpServers = ['servers.mcp.json']; - await writeFile(path.join(dir, '.minimax-plugin', 'plugin.json'), `${JSON.stringify(pluginManifest)}\n`); - await writeFile(path.join(dir, 'servers.mcp.json'), JSON.stringify({ mcpServers: { local: {} } })); - const miniappManifest = JSON.parse(await readFile(path.join(dir, 'miniapp', 'miniapp.json'), 'utf8')); - miniappManifest.mcpEndpoints = [{ server: 'local', path: '/mcp' }]; - await writeFile(path.join(dir, 'miniapp', 'miniapp.json'), `${JSON.stringify(miniappManifest)}\n`); - assert.deepEqual(await validatePackage(dir), []); - - miniappManifest.mcpEndpoints = [{ server: 'missing', path: '/mcp' }]; - await writeFile(path.join(dir, 'miniapp', 'miniapp.json'), `${JSON.stringify(miniappManifest)}\n`); - assert.deepEqual(codes(await validatePackage(dir)), ['MINIAPP_MCP_ENDPOINT_INVALID']); -}); - test('package: an example is not subject to the lowercase-author rule', async () => { const root = await makeTmpRoot(); const { dir } = await copyExample(root, { author: 'Examples' }); diff --git a/test/paths.test.mjs b/test/paths.test.mjs index 2a3d9fc..8860501 100644 --- a/test/paths.test.mjs +++ b/test/paths.test.mjs @@ -5,7 +5,6 @@ import { normalizePluginPath, normalizeRoutePath, portablePathIssue, - resolvePackagePath, } from '../scripts/lib/paths.mjs'; test('normalizePluginPath strips ./ and rejects non-canonical input', () => { @@ -28,12 +27,6 @@ test('portablePathIssue mirrors the Host rules', () => { assert.match(portablePathIssue(Array.from({ length: 17 }, () => 'a').join('/')), /too many segments/); }); -test('resolvePackagePath never resolves outside the package root', () => { - assert.equal(resolvePackagePath('/tmp/package', 'docs/readme.md'), '/tmp/package/docs/readme.md'); - assert.equal(resolvePackagePath('/tmp/package', '../outside.txt'), undefined); - assert.equal(resolvePackagePath('/tmp/package', 'nested/../../outside.txt'), undefined); -}); - test('normalizeRoutePath adds the leading slash and rejects transport syntax', () => { assert.deepEqual(normalizeRoutePath('dashboard'), { ok: true, value: '/dashboard' }); assert.deepEqual(normalizeRoutePath('/dashboard'), { ok: true, value: '/dashboard' }); From d55bdd3ebb4b3accd859911c3611b83685e2fa40 Mon Sep 17 00:00:00 2001 From: dazhi <dazhi@minimaxi.com> Date: Wed, 23 Sep 2026 17:53:17 +0800 Subject: [PATCH 14/14] docs: name the plugins directory in the example README and scope host-enforced rules --- docs/package-contract.md | 10 +++++++--- examples/hello-miniapp/README.md | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/package-contract.md b/docs/package-contract.md index ceab2cf..10c997b 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -2,8 +2,11 @@ Verified against MiniMax Code 3.0.73. -A Mini App is a MiniMax Plugin whose `package.json` declares a Mini App payload. Everything below is -enforced by MiniMax Code when the package is installed; `npm run check` enforces the same rules here. +A Mini App is a MiniMax Plugin whose `package.json` declares a Mini App payload. MiniMax Code +enforces the manifest, payload, and path rules below when the package is installed. `npm run check` +enforces the same rules here, plus this repository's own requirements: `README.md` and `LICENSE`, +a real image file behind `icon`, `lifecycle` limited to `on-demand`, no hard links, no committed +`node_modules`, and the Node entry conventions in `docs/runtime.md`. ## Layout @@ -93,7 +96,8 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, Each path must exist. These are the runtime payload roots: exactly what MiniMax Code hashes and installs. Any `node_modules` directory is excluded from payloads. - `runtime.kind` is `process`. `runtime.entry` ends in `.js`, `.mjs`, or `.cjs`, exists, and lies - inside one of `artifacts.node`. `runtime.lifecycle` is `on-demand` or omitted. + inside one of `artifacts.node`. Use `.mjs`: this repository checks the entry for an ESM `start` + export (see `docs/runtime.md`). `runtime.lifecycle` is `on-demand` or omitted. - `surface.path` is the route the Node entry serves the page on. It is relative to the Host and must not contain an origin, query, fragment, or backslash. A missing leading `/` is added. - `mcpEndpoints` is an array of `{ "server": string, "path": string }`. `server` matches diff --git a/examples/hello-miniapp/README.md b/examples/hello-miniapp/README.md index 67916b3..836b505 100644 --- a/examples/hello-miniapp/README.md +++ b/examples/hello-miniapp/README.md @@ -7,8 +7,9 @@ holder. ## Install -Copy this directory, including the hidden `.minimax-plugin/`, to `<dataDir>/plugins/hello-miniapp/` -(by default `~/.minimax/plugins/hello-miniapp/`). Restart MiniMax Code and ask the Agent to +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `hello-miniapp/` (`~/.minimax/plugins/hello-miniapp/` by default; the root README's +Install section explains where that directory is). Restart MiniMax Code and ask the Agent to "Open Hello Mini App". ## Tested environment