Skip to content

Commit f9ab13e

Browse files
author
dazhi
committed
Harden community MiniApp package checks
Assisted-by: codex reason:community-contract-validator
1 parent bb8309f commit f9ab13e

15 files changed

Lines changed: 174 additions & 38 deletions

File tree

‎docs/package-contract.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`,
9696
inside one of `artifacts.node`. `runtime.lifecycle` is `on-demand` or omitted.
9797
- `surface.path` is the route the Node entry serves the page on. It is relative to the Host and
9898
must not contain an origin, query, fragment, or backslash. A missing leading `/` is added.
99-
- `mcpEndpoints` is an array of `{ "server": string, "path": string }`. `server` matches
100-
`^[a-zA-Z0-9_-]{1,128}$` and must name a server declared through `plugin.json.mcpServers`; MiniMax
101-
Code checks that reference at install time. `server` and `path` are each unique. Use `[]` when
102-
there are none.
99+
- `mcpEndpoints` is an array of `{ "server": string, "path": string }`; include `[]` when there
100+
are none. `server` matches `^[a-zA-Z0-9_-]{1,128}$` and must name a server declared inside one
101+
of the `plugin.json.mcpServers` descriptor files. `npm run check` reads those descriptor files
102+
and checks the reference when they are valid JSON; MiniMax Code repeats the check at install time.
103+
`server` and `path` are each unique.
103104
- `hostConnectorAccess` is optional: `{ "providers": string[] }`, each matching
104105
`^[a-z0-9_-]{1,64}$` and unique. Declared providers are granted by the Host at install time; this
105106
repository does not document their use.

‎plugins/amszuidas/mcode-token-usage-board/README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,23 @@ Include the hidden `.minimax-plugin` directory. Restart a version of MiniMax Cod
2222

2323
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.
2424

25-
## Data access and counting
25+
## Data & access
2626

2727
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.
2828

2929
Usage is grouped by day in your local time zone. The total adds input, output, cache-read, and cache-write Tokens; it is not an official billable Token count or invoice. The app depends on the client's session file format. Missing usage fields or format changes may result in incomplete statistics.
3030

3131
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.
3232

33+
## Tested environment
34+
35+
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.
36+
3337
## Source and verification
3438

3539
The page is in `miniapp/client/index.html`, and the Node service is in `miniapp/node/server.mjs`. No build step is required.
3640

37-
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.
41+
Checks performed when adding this app covered usage aggregation, data refresh, error handling, and service shutdown with synthetic sessions, plus a browser preview.
3842

3943
## License
4044

‎plugins/yanhy2000/mcode-usage-monitor/README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The page opens on the last 24 hours. You can switch between 1 hour, 24 hours, 7
2424

2525
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.
2626

27-
## Data access and counting
27+
## Data & access
2828

2929
The app reads two local sources:
3030

@@ -40,11 +40,15 @@ This is a local, near-real-time view. The in-product usage page (Settings → Us
4040

4141
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.
4242

43+
## Tested environment
44+
45+
Verified environment: MiniMax Code desktop `3.0.73.166` on Windows (10.0.26200, x64). macOS and Linux are unverified.
46+
4347
## Source and verification
4448

4549
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.
4650

47-
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.
51+
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.
4852

4953
Third-party components: [ECharts](https://echarts.apache.org/) (Apache License 2.0), bundled locally for offline use.
5054

‎scripts/lib/contract.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,6 @@ export const REQUIRED_FILES = [
103103
export const README_HEADINGS = ['## Tested environment', '## Data & access'];
104104

105105
export const STDOUT_CALL = /\b(?:console\.(?:log|info|debug|dir|table)|process\.stdout\.write)\s*\(/u;
106-
export const START_EXPORT =
107-
/^\s*export\s+(?:async\s+function\s+start\b|function\s+start\b|const\s+start\b|let\s+start\b|\{[^}]*\bstart\b[^}]*\})/mu;
106+
export const START_EXPORT_DECLARATION =
107+
/^\s*export\s+(?:(?:async\s+)?function\s+start\b|(?:const|let|var)\s+start\b)/mu;
108+
export const START_EXPORT_LIST = /^\s*export\s*\{([^}]*)\}/gmu;

‎scripts/lib/package.mjs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { checkReadme } from './rules/readme.mjs';
1313
export async function validatePackage(packageDir, { requireLowercaseAuthor = true } = {}) {
1414
const dir = path.resolve(packageDir);
1515
const report = createReport();
16+
let mcpServerNames = [];
1617

1718
await checkLayout(report, { packageDir: dir, requireLowercaseAuthor });
1819

1920
const manifest = await readJson(report, dir, '.minimax-plugin/plugin.json', 'MANIFEST_UNREADABLE');
2021
if (manifest.present) {
2122
const identity = await checkManifest(report, { packageDir: dir, manifest: manifest.value });
23+
mcpServerNames = identity.mcpServerNames ?? [];
2224
await checkNameMatchesDirectory(report, { packageDir: dir, name: identity.name });
2325
if (identity.icon) await checkImageFile(report, { packageDir: dir, relativePath: identity.icon, label: 'icon' });
2426
if (identity.darkIcon) await checkImageFile(report, { packageDir: dir, relativePath: identity.darkIcon, label: 'darkIcon' });
@@ -29,7 +31,7 @@ export async function validatePackage(packageDir, { requireLowercaseAuthor = tru
2931

3032
const miniapp = await readJson(report, dir, 'miniapp/miniapp.json', 'MINIAPP_UNREADABLE');
3133
let resolved = { nodeRoots: [], clientRoots: [] };
32-
if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value });
34+
if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value, mcpServerNames });
3335

3436
await checkFiles(report, { packageDir: dir });
3537
await checkReadme(report, { packageDir: dir });

‎scripts/lib/paths.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export function normalizePluginPath(value) {
1414
return { ok: true, value: normalized };
1515
}
1616

17+
export function resolvePackagePath(packageDir, relativePath) {
18+
const root = path.resolve(packageDir);
19+
const absolute = path.resolve(root, ...relativePath.split('/'));
20+
return absolute === root || absolute.startsWith(`${root}${path.sep}`) ? absolute : undefined;
21+
}
22+
1723
export function portablePathIssue(relativePath) {
1824
if (!relativePath) return 'path is empty';
1925
if (!/^[\x00-\x7f]*$/u.test(relativePath)) return 'is not ASCII';

‎scripts/lib/rules/layout.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'node:path';
33

44
import { REQUIRED_FILES } from '../contract.mjs';
55
import { pathExists } from '../fs.mjs';
6+
import { resolvePackagePath } from '../paths.mjs';
67

78
export async function checkLayout(report, { packageDir, requireLowercaseAuthor }) {
89
if (requireLowercaseAuthor) {
@@ -36,7 +37,11 @@ const SIGNATURES = [
3637
];
3738

3839
export async function checkImageFile(report, { packageDir, relativePath, label }) {
39-
const absolute = path.join(packageDir, ...relativePath.split('/'));
40+
const absolute = resolvePackagePath(packageDir, relativePath);
41+
if (absolute === undefined) {
42+
report.error('LAYOUT_IMAGE_INVALID', `${label} path must stay inside the package`, relativePath);
43+
return;
44+
}
4045
if (!(await pathExists(absolute, 'file'))) {
4146
report.error('LAYOUT_IMAGE_INVALID', `${label} file does not exist`, relativePath);
4247
return;

‎scripts/lib/rules/manifest.mjs‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import path from 'node:path';
2-
31
import {
42
APP_PATH,
53
CATEGORIES,
@@ -15,7 +13,8 @@ import {
1513
SKILL_PATH,
1614
VERSION_MAX_LENGTH,
1715
} from '../contract.mjs';
18-
import { isRecord, pathExists } from '../fs.mjs';
16+
import { isRecord, pathExists, readJsonFile } from '../fs.mjs';
17+
import { portablePathIssue, resolvePackagePath } from '../paths.mjs';
1918

2019
const FILE = '.minimax-plugin/plugin.json';
2120

@@ -63,7 +62,8 @@ export async function checkManifest(report, { packageDir, manifest }) {
6362
if (apps && apps.length > 0) {
6463
report.warning('MANIFEST_APPS_IGNORED', 'apps entries are ignored for locally installed packages', FILE);
6564
}
66-
await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true });
65+
const mcpServers = await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true });
66+
const mcpServerNames = await readMcpServerNames(packageDir, mcpServers);
6767
await references(report, packageDir, manifest.skills, 'skills', SKILL_PATH, { required: true, mustExist: true });
6868
await references(report, packageDir, manifest.hooks, 'hooks', HOOK_PATH, { required: false, mustExist: true });
6969
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 }) {
7272
if (name !== undefined) identity.name = name;
7373
if (icon !== undefined) identity.icon = icon;
7474
if (darkIcon !== undefined) identity.darkIcon = darkIcon;
75+
identity.mcpServerNames = mcpServerNames;
7576
return identity;
7677
}
7778

@@ -103,7 +104,7 @@ function imagePath(report, value, label, required) {
103104
return undefined;
104105
}
105106
const trimmed = value.trim();
106-
if (trimmed.length > REFERENCE_MAX_LENGTH || !ICON_PATH.test(trimmed)) {
107+
if (trimmed.length > REFERENCE_MAX_LENGTH || portablePathIssue(trimmed) || !ICON_PATH.test(trimmed)) {
107108
report.error('MANIFEST_FIELD_INVALID', `${label} must be a plugin-relative .png, .jpg, .jpeg, or .webp path`, FILE);
108109
return undefined;
109110
}
@@ -130,13 +131,32 @@ async function references(report, packageDir, value, label, pattern, { required,
130131
return items;
131132
}
132133
for (const item of items) {
133-
if (item.length > REFERENCE_MAX_LENGTH || !pattern.test(item)) {
134+
const issue = portablePathIssue(item);
135+
if (item.length > REFERENCE_MAX_LENGTH || issue || !pattern.test(item)) {
134136
report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must match ${pattern.source}`, FILE);
135137
continue;
136138
}
137-
if (mustExist && !(await pathExists(path.join(packageDir, ...item.split('/')), 'file'))) {
139+
const absolute = resolvePackagePath(packageDir, item);
140+
if (absolute === undefined) {
141+
report.error('MANIFEST_REFERENCE_INVALID', `${label} entry "${item}" must stay inside the package`, FILE);
142+
continue;
143+
}
144+
if (mustExist && !(await pathExists(absolute, 'file'))) {
138145
report.error('MANIFEST_REFERENCE_MISSING', `${label} entry "${item}" does not exist`, item);
139146
}
140147
}
141148
return items;
142149
}
150+
151+
async function readMcpServerNames(packageDir, references) {
152+
if (!references) return [];
153+
const names = new Set();
154+
for (const relativePath of references) {
155+
const absolute = resolvePackagePath(packageDir, relativePath);
156+
if (absolute === undefined) continue;
157+
const result = await readJsonFile(absolute);
158+
if (!result.ok || !isRecord(result.value) || !isRecord(result.value.mcpServers)) continue;
159+
for (const name of Object.keys(result.value.mcpServers)) names.add(name);
160+
}
161+
return [...names];
162+
}

‎scripts/lib/rules/miniapp.mjs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.m
1818

1919
const FILE = 'miniapp/miniapp.json';
2020

21-
export async function checkMiniApp(report, { packageDir, manifest }) {
21+
export async function checkMiniApp(report, { packageDir, manifest, mcpServerNames = [] }) {
2222
const resolved = { nodeRoots: [], clientRoots: [] };
2323
if (!isRecord(manifest)) {
2424
report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE);
@@ -35,7 +35,7 @@ export async function checkMiniApp(report, { packageDir, manifest }) {
3535
const entry = await checkRuntime(report, packageDir, manifest.runtime, artifacts.node);
3636
if (entry) resolved.entry = entry;
3737
checkSurface(report, manifest.surface);
38-
checkMcpEndpoints(report, manifest.mcpEndpoints);
38+
checkMcpEndpoints(report, manifest.mcpEndpoints, mcpServerNames);
3939
checkHostConnectorAccess(report, manifest.hostConnectorAccess);
4040
return resolved;
4141
}
@@ -120,12 +120,16 @@ function checkSurface(report, value) {
120120
if (!route.ok) report.error('MINIAPP_SURFACE_INVALID', `surface.path ${route.reason}`, FILE);
121121
}
122122

123-
function checkMcpEndpoints(report, value) {
124-
if (value === undefined) return;
123+
function checkMcpEndpoints(report, value, mcpServerNames) {
124+
if (value === undefined) {
125+
report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE);
126+
return;
127+
}
125128
if (!Array.isArray(value)) {
126129
report.error('MINIAPP_MCP_ENDPOINT_INVALID', 'mcpEndpoints must be an array (use [] when empty)', FILE);
127130
return;
128131
}
132+
const knownServers = new Set(mcpServerNames);
129133
const servers = new Set();
130134
const paths = new Set();
131135
for (const item of value) {
@@ -137,6 +141,10 @@ function checkMcpEndpoints(report, value) {
137141
report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint server must match ${MCP_SERVER_NAME.source}`, FILE);
138142
continue;
139143
}
144+
if (!knownServers.has(item.server)) {
145+
report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP server "${item.server}" is not declared by plugin.json.mcpServers`, FILE);
146+
continue;
147+
}
140148
const route = normalizeRoutePath(item.path);
141149
if (!route.ok) {
142150
report.error('MINIAPP_MCP_ENDPOINT_INVALID', `MCP endpoint path ${route.reason}`, FILE);

‎scripts/lib/rules/node.mjs‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
33
import path from 'node:path';
44
import { promisify } from 'node:util';
55

6-
import { START_EXPORT, STDOUT_CALL } from '../contract.mjs';
6+
import { START_EXPORT_DECLARATION, START_EXPORT_LIST, STDOUT_CALL } from '../contract.mjs';
77
import { pathExists } from '../fs.mjs';
88

99
const execFileAsync = promisify(execFile);
@@ -25,26 +25,34 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) {
2525
const absolute = path.join(packageDir, ...relativePath.split('/'));
2626
if (!(await pathExists(absolute, 'file'))) continue;
2727
const isEntry = relativePath === entry;
28-
if (path.extname(relativePath) !== '.js') {
29-
try {
30-
await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true });
31-
} catch (error) {
32-
report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath);
33-
continue;
34-
}
28+
try {
29+
await execFileAsync(process.execPath, ['--check', absolute], { windowsHide: true });
30+
} catch (error) {
31+
report.error('ENTRY_SYNTAX', firstLine(error.stderr) || 'syntax check failed', relativePath);
32+
continue;
3533
}
3634
const source = await readFile(absolute, 'utf8');
3735
if (STDOUT_CALL.test(source)) {
3836
const message = 'writes to stdout, which belongs to the Host; log through context.logger instead';
3937
if (isEntry) report.error('ENTRY_STDOUT', message, relativePath);
4038
else report.warning('ENTRY_STDOUT', message, relativePath);
4139
}
42-
if (isEntry && !START_EXPORT.test(source)) {
40+
if (isEntry && !hasNamedStartExport(source)) {
4341
report.error('ENTRY_START_EXPORT_MISSING', 'the Node entry must export a named start function using ESM syntax', relativePath);
4442
}
4543
}
4644
}
4745

46+
function hasNamedStartExport(source) {
47+
if (START_EXPORT_DECLARATION.test(source)) return true;
48+
START_EXPORT_LIST.lastIndex = 0;
49+
for (const match of source.matchAll(START_EXPORT_LIST)) {
50+
const specifiers = match[1].split(',').map((specifier) => specifier.trim());
51+
if (specifiers.some((specifier) => /^(?:start|[A-Za-z_$][\w$]*\s+as\s+start|default\s+as\s+start)$/u.test(specifier))) return true;
52+
}
53+
return false;
54+
}
55+
4856
async function collectScripts(absoluteDir, relativeDir) {
4957
const files = [];
5058
for (const dirent of await readdir(absoluteDir, { withFileTypes: true })) {

0 commit comments

Comments
 (0)