From 842f3dede0436fd919bf28c8383f8549e47ba8a7 Mon Sep 17 00:00:00 2001 From: dazhi Date: Fri, 16 Oct 2026 17:35:40 +0800 Subject: [PATCH 1/2] feat(check): accept the MiniMax Code 3.1.2 package fields and guard the loopback port rules The checker mirrors the 3.1.2 package reader: - miniapp.json accepts agentAccess (only "session.run") and agentConcurrency (integer 1-32). - surface.window is validated with the Host's rule set: whitelisted fields, dimensions 200-10000, min* not above the size, booleans, and transparent only with frame: false. - plugin.json accepts agents (agents/.md, file must exist). Other unknown fields stay errors because clients before 3.1.2 reject them. Clients before 3.1.2 reject a package that declares any of these, so FEATURE_MIN_HOST_VERSION records the minimum per feature, and README_MIN_VERSION_MISSING warns until the README's "## Tested environment" names a MiniMax Code version at or above it. The Host now authenticates the assigned port inside node:http and owns /__mavis/: - surface.path or an MCP endpoint path under /__mavis/ is an error (MINIAPP_ROUTE_RESERVED). - A Node file that creates an https, http2, net, or tls server, or mentions a /__mavis/ route, gets a warning (NODE_UNGUARDED_SERVER, NODE_RESERVED_ROUTE). Assisted-by: Claude Opus 5.5 (OpenCode) --- scripts/lib/contract.mjs | 36 ++++++++++++- scripts/lib/package.mjs | 7 ++- scripts/lib/rules/manifest.mjs | 4 +- scripts/lib/rules/miniapp.mjs | 74 +++++++++++++++++++++++++- scripts/lib/rules/node.mjs | 8 ++- scripts/lib/rules/readme.mjs | 42 +++++++++++++-- test/manifest.test.mjs | 25 ++++++++- test/miniapp.test.mjs | 96 +++++++++++++++++++++++++++++++++- test/node.test.mjs | 31 +++++++++++ test/package.test.mjs | 17 +++++- test/readme.test.mjs | 26 ++++++++- 11 files changed, 350 insertions(+), 16 deletions(-) diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs index 361c237..7c2906a 100644 --- a/scripts/lib/contract.mjs +++ b/scripts/lib/contract.mjs @@ -1,6 +1,15 @@ // Constants shared by every rule. Values mirror the MiniMax Code package reader. -export const HOST_VERSION = '3.0.73'; +export const HOST_VERSION = '3.1.2'; + +// Clients older than the listed version reject the whole package when it declares the feature. +// Keys are the names diagnostics and docs use for each feature. +export const FEATURE_MIN_HOST_VERSION = Object.freeze({ + agents: '3.1.2', + agentAccess: '3.1.2', + agentConcurrency: '3.1.2', + 'surface.window': '3.1.2', +}); export const MANIFEST_FIELDS = new Set([ '$schema', @@ -19,6 +28,7 @@ export const MANIFEST_FIELDS = new Set([ 'skills', 'hooks', 'hostBindings', + 'agents', ]); export const PLUGIN_NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; @@ -35,6 +45,7 @@ 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 AGENT_PATH = /^agents\/[a-z][a-z0-9_-]*\.md$/u; export const CATEGORIES = [ 'Office', @@ -61,10 +72,20 @@ export const MINIAPP_FIELDS = new Set([ 'surface', 'mcpEndpoints', 'hostConnectorAccess', + 'agentAccess', + 'agentConcurrency', ]); 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 SURFACE_FIELDS = new Set(['path', 'window']); +export const SURFACE_WINDOW_DIMENSION_FIELDS = ['width', 'height', 'minWidth', 'minHeight']; +export const SURFACE_WINDOW_BOOLEAN_FIELDS = ['resizable', 'frame', 'transparent', 'alwaysOnTop', 'skipTaskbar', 'clickThrough']; +export const SURFACE_WINDOW_DIMENSION_MIN = 200; +export const SURFACE_WINDOW_DIMENSION_MAX = 10_000; +export const AGENT_ACCESS_CAPABILITIES = new Set(['session.run']); +export const AGENT_CONCURRENCY_MAX = 32; +// The Host answers requests under this prefix itself; plugin routes there never work. +export const RESERVED_ROUTE_PREFIX = '/__mavis'; 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; @@ -105,3 +126,14 @@ 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; +// The Host's loopback guard patches node:http only; servers from these modules never pass readiness. +const UNGUARDED_MODULE = String.raw`['"](?:node:)?(?:https|http2|net|tls)['"]`; +export const UNGUARDED_SERVER = new RegExp( + [ + String.raw`\b(?:https|http2|net|tls)\s*\.\s*create(?:Secure)?Server\s*\(`, + String.raw`\bimport\s*\{[^}]*\bcreate(?:Secure)?Server\b[^}]*\}\s*from\s*${UNGUARDED_MODULE}`, + String.raw`\{[^}]*\bcreate(?:Secure)?Server\b[^}]*\}\s*=\s*require\(\s*${UNGUARDED_MODULE}\s*\)`, + ].join('|'), + 'u', +); +export const RESERVED_ROUTE_LITERAL = /\/__mavis(?:\/|['"`])/u; diff --git a/scripts/lib/package.mjs b/scripts/lib/package.mjs index d106262..34e12fb 100644 --- a/scripts/lib/package.mjs +++ b/scripts/lib/package.mjs @@ -17,8 +17,10 @@ export async function validatePackage(packageDir, { requireLowercaseAuthor = tru await checkLayout(report, { packageDir: dir, requireLowercaseAuthor }); const manifest = await readJson(report, dir, '.minimax-plugin/plugin.json', 'MANIFEST_UNREADABLE'); + const features = []; if (manifest.present) { const identity = await checkManifest(report, { packageDir: dir, manifest: manifest.value }); + features.push(...(identity.features ?? [])); 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' }); @@ -28,11 +30,12 @@ export async function validatePackage(packageDir, { requireLowercaseAuthor = tru if (packageJson.present) checkMcode(report, { packageJson: packageJson.value }); const miniapp = await readJson(report, dir, 'miniapp/miniapp.json', 'MINIAPP_UNREADABLE'); - let resolved = { nodeRoots: [], clientRoots: [] }; + let resolved = { nodeRoots: [], clientRoots: [], features: [] }; if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value }); + features.push(...resolved.features); await checkFiles(report, { packageDir: dir }); - await checkReadme(report, { packageDir: dir }); + await checkReadme(report, { packageDir: dir, features }); await checkNode(report, { packageDir: dir, entry: resolved.entry, nodeRoots: resolved.nodeRoots }); return report.diagnostics; diff --git a/scripts/lib/rules/manifest.mjs b/scripts/lib/rules/manifest.mjs index 247fd9f..176982f 100644 --- a/scripts/lib/rules/manifest.mjs +++ b/scripts/lib/rules/manifest.mjs @@ -1,6 +1,7 @@ import path from 'node:path'; import { + AGENT_PATH, APP_PATH, CATEGORIES, HOOK_PATH, @@ -67,8 +68,9 @@ export async function checkManifest(report, { packageDir, manifest }) { 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 }); + await references(report, packageDir, manifest.agents, 'agents', AGENT_PATH, { required: false, mustExist: true }); - const identity = {}; + const identity = { features: manifest.agents === undefined ? [] : ['agents'] }; if (name !== undefined) identity.name = name; if (icon !== undefined) identity.icon = icon; if (darkIcon !== undefined) identity.darkIcon = darkIcon; diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index 6ad94f9..6cadcba 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -1,6 +1,8 @@ import path from 'node:path'; import { + AGENT_ACCESS_CAPABILITIES, + AGENT_CONCURRENCY_MAX, ARTIFACT_FIELDS, CONNECTOR_PROVIDER, ENTRY_EXTENSION, @@ -10,8 +12,13 @@ import { MCP_SERVER_NAME, MINIAPP_FIELDS, PAYLOAD_DIRECTORY, + RESERVED_ROUTE_PREFIX, RUNTIME_FIELDS, SURFACE_FIELDS, + SURFACE_WINDOW_BOOLEAN_FIELDS, + SURFACE_WINDOW_DIMENSION_FIELDS, + SURFACE_WINDOW_DIMENSION_MAX, + SURFACE_WINDOW_DIMENSION_MIN, } from '../contract.mjs'; import { isRecord, pathExists } from '../fs.mjs'; import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.mjs'; @@ -19,7 +26,7 @@ import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.m const FILE = 'miniapp/miniapp.json'; export async function checkMiniApp(report, { packageDir, manifest }) { - const resolved = { nodeRoots: [], clientRoots: [] }; + const resolved = { nodeRoots: [], clientRoots: [], features: [] }; if (!isRecord(manifest)) { report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE); return resolved; @@ -37,6 +44,11 @@ export async function checkMiniApp(report, { packageDir, manifest }) { checkSurface(report, manifest.surface); checkMcpEndpoints(report, manifest.mcpEndpoints); checkHostConnectorAccess(report, manifest.hostConnectorAccess); + checkAgentAccess(report, manifest.agentAccess); + checkAgentConcurrency(report, manifest.agentConcurrency); + if (manifest.agentAccess !== undefined) resolved.features.push('agentAccess'); + if (manifest.agentConcurrency !== undefined) resolved.features.push('agentConcurrency'); + if (isRecord(manifest.surface) && manifest.surface.window !== undefined) resolved.features.push('surface.window'); return resolved; } @@ -113,11 +125,54 @@ async function checkRuntime(report, packageDir, value, nodeRoots) { 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); + report.error('MINIAPP_SURFACE_INVALID', 'surface must be an object with only "path" and "window"', FILE); return; } const route = normalizeRoutePath(value.path); if (!route.ok) report.error('MINIAPP_SURFACE_INVALID', `surface.path ${route.reason}`, FILE); + else checkRouteNotReserved(report, route.value, 'surface.path'); + if (value.window !== undefined) checkSurfaceWindow(report, value.window); +} + +// Same rule set as the Host's validateMiniAppSurfaceWindow. +function checkSurfaceWindow(report, value) { + if (!isRecord(value)) { + report.error('MINIAPP_SURFACE_INVALID', 'surface.window must be an object', FILE); + return; + } + const known = new Set([...SURFACE_WINDOW_DIMENSION_FIELDS, ...SURFACE_WINDOW_BOOLEAN_FIELDS]); + for (const key of Object.keys(value)) { + if (!known.has(key)) report.error('MINIAPP_SURFACE_INVALID', `surface.window has unknown field "${key}"`, FILE); + } + const dimensions = {}; + for (const key of SURFACE_WINDOW_DIMENSION_FIELDS) { + const entry = value[key]; + if (entry === undefined) continue; + if (!Number.isInteger(entry) || entry < SURFACE_WINDOW_DIMENSION_MIN || entry > SURFACE_WINDOW_DIMENSION_MAX) { + report.error('MINIAPP_SURFACE_INVALID', `surface.window.${key} must be an integer from ${SURFACE_WINDOW_DIMENSION_MIN} to ${SURFACE_WINDOW_DIMENSION_MAX}`, FILE); + continue; + } + dimensions[key] = entry; + } + for (const [minKey, key] of [['minWidth', 'width'], ['minHeight', 'height']]) { + if (dimensions[minKey] !== undefined && dimensions[key] !== undefined && dimensions[minKey] > dimensions[key]) { + report.error('MINIAPP_SURFACE_INVALID', `surface.window.${minKey} must not exceed surface.window.${key}`, FILE); + } + } + for (const key of SURFACE_WINDOW_BOOLEAN_FIELDS) { + if (value[key] !== undefined && typeof value[key] !== 'boolean') { + report.error('MINIAPP_SURFACE_INVALID', `surface.window.${key} must be a boolean`, FILE); + } + } + if (value.transparent === true && value.frame !== false) { + report.error('MINIAPP_SURFACE_INVALID', 'surface.window.transparent requires surface.window.frame to be false', FILE); + } +} + +function checkRouteNotReserved(report, route, label) { + if (route === RESERVED_ROUTE_PREFIX || route.startsWith(`${RESERVED_ROUTE_PREFIX}/`)) { + report.error('MINIAPP_ROUTE_RESERVED', `${label} "${route}" is under ${RESERVED_ROUTE_PREFIX}/, which the Host reserves`, FILE); + } } function checkMcpEndpoints(report, value) { @@ -145,6 +200,7 @@ function checkMcpEndpoints(report, value) { 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); } + checkRouteNotReserved(report, route.value, 'MCP endpoint path'); servers.add(item.server); paths.add(route.value); } @@ -159,3 +215,17 @@ function checkHostConnectorAccess(report, value) { } report.warning('HOST_CONNECTOR_UNVERIFIED', 'declared providers are granted by the Host at install time and are not checked here', FILE); } + +function checkAgentAccess(report, value) { + if (value === undefined) return; + if (!Array.isArray(value) || value.some((capability) => !AGENT_ACCESS_CAPABILITIES.has(capability))) { + report.error('MINIAPP_AGENT_ACCESS_INVALID', `agentAccess must be an array containing only ${[...AGENT_ACCESS_CAPABILITIES].map((c) => `"${c}"`).join(', ')}`, FILE); + } +} + +function checkAgentConcurrency(report, value) { + if (value === undefined) return; + if (!Number.isInteger(value) || value < 1 || value > AGENT_CONCURRENCY_MAX) { + report.error('MINIAPP_AGENT_CONCURRENCY_INVALID', `agentConcurrency must be an integer from 1 to ${AGENT_CONCURRENCY_MAX}`, FILE); + } +} diff --git a/scripts/lib/rules/node.mjs b/scripts/lib/rules/node.mjs index 8408f1f..6327366 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 { RESERVED_ROUTE_LITERAL, START_EXPORT, STDOUT_CALL, UNGUARDED_SERVER } from '../contract.mjs'; import { pathExists } from '../fs.mjs'; const execFileAsync = promisify(execFile); @@ -42,6 +42,12 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) { 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); } + if (UNGUARDED_SERVER.test(source)) { + report.warning('NODE_UNGUARDED_SERVER', "creates an https, http2, net, or tls server; the Host's loopback guard covers only node:http, so context.listen.port must be served by node:http or the Mini App never becomes ready", relativePath); + } + if (RESERVED_ROUTE_LITERAL.test(source)) { + report.warning('NODE_RESERVED_ROUTE', 'mentions a /__mavis/ route; the Host reserves that prefix and plugin routes under it do not work', relativePath); + } } } diff --git a/scripts/lib/rules/readme.mjs b/scripts/lib/rules/readme.mjs index 1e9288a..3e4f79a 100644 --- a/scripts/lib/rules/readme.mjs +++ b/scripts/lib/rules/readme.mjs @@ -1,15 +1,51 @@ import path from 'node:path'; -import { README_HEADINGS } from '../contract.mjs'; +import { FEATURE_MIN_HOST_VERSION, README_HEADINGS } from '../contract.mjs'; import { readText } from '../fs.mjs'; -export async function checkReadme(report, { packageDir }) { +const TESTED_ENVIRONMENT = '## Tested environment'; +const HOST_VERSION_MENTION = /MiniMax Code\D{0,16}?(\d+)\.(\d+)\.(\d+)/gu; + +export async function checkReadme(report, { packageDir, features = [] }) { 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())); + const lines = text.split(/\r?\n/u); + const headings = new Set(lines.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'); } } + checkMinimumVersion(report, lines, features); +} + +function checkMinimumVersion(report, lines, features) { + if (features.length === 0) return; + const required = features.map((feature) => FEATURE_MIN_HOST_VERSION[feature]).reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b)); + const tested = testedEnvironmentSection(lines); + const satisfied = [...tested.matchAll(HOST_VERSION_MENTION)].some((match) => compareVersions(match.slice(1, 4).join('.'), required) >= 0); + if (!satisfied) { + report.warning( + 'README_MIN_VERSION_MISSING', + `declares ${features.join(', ')}, which need MiniMax Code ${required} or later; name that version under "${TESTED_ENVIRONMENT}"`, + 'README.md', + ); + } +} + +function testedEnvironmentSection(lines) { + const start = lines.findIndex((line) => line.trim() === TESTED_ENVIRONMENT); + if (start === -1) return ''; + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => /^#{1,2}\s/u.test(line)); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +} + +function compareVersions(a, b) { + const left = a.split('.').map(Number); + const right = b.split('.').map(Number); + for (let i = 0; i < 3; i += 1) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return 0; } diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index f517eeb..38addaa 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', features: [] }); }); test('manifest: $schema is an accepted optional string', async () => { @@ -82,6 +82,29 @@ test('manifest: skills, mcpServers, hooks, hostBindings need matching pattern an assert.deepEqual(codes(duplicate.report.diagnostics), ['MANIFEST_REFERENCE_INVALID']); }); +test('manifest: agents are agents/.md files that exist, and report the feature', async () => { + const present = await run( + (m) => ({ ...m, agents: ['agents/route-planner.md'] }), + async (dir) => { + await mkdir(path.join(dir, 'agents'), { recursive: true }); + await writeFile(path.join(dir, 'agents', 'route-planner.md'), '# route planner\n'); + }, + ); + assert.deepEqual(present.report.diagnostics, []); + assert.deepEqual(present.identity.features, ['agents']); + const missing = await run((m) => ({ ...m, agents: ['agents/route-planner.md'] })); + assert.deepEqual(codes(missing.report.diagnostics), ['MANIFEST_REFERENCE_MISSING']); + for (const entry of ['agents/Planner.md', 'agents/nested/planner.md', 'planner.md', 'agents/planner.txt']) { + const bad = await run((m) => ({ ...m, agents: [entry] })); + assert.deepEqual(codes(bad.report.diagnostics), ['MANIFEST_REFERENCE_INVALID'], entry); + } +}); + +test('manifest: hostCapabilities cannot be claimed by a manifest', async () => { + const { report } = await run((m) => ({ ...m, hostCapabilities: [{ id: 'browser', version: 1 }] })); + assert.deepEqual(codes(report.diagnostics), ['MANIFEST_UNKNOWN_FIELD']); +}); + 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..14aedc0 100644 --- a/test/miniapp.test.mjs +++ b/test/miniapp.test.mjs @@ -21,7 +21,12 @@ async function run(mutate, prepare) { 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'] }); + assert.deepEqual(resolved, { + entry: 'miniapp/node/server.mjs', + nodeRoots: ['miniapp/node'], + clientRoots: ['miniapp/client'], + features: [], + }); }); test('miniapp: lifecycle may be omitted; other values are errors', async () => { @@ -82,6 +87,95 @@ test('miniapp: hostConnectorAccess is validated for shape and reported as unveri assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_HOST_CONNECTOR_INVALID']); }); +test('miniapp: surface.window accepts every whitelisted field and reports the feature', async () => { + const { report, resolved } = await run((m) => { + m.surface.window = { + width: 960, + height: 700, + minWidth: 480, + minHeight: 360, + resizable: true, + frame: false, + transparent: true, + alwaysOnTop: false, + skipTaskbar: false, + clickThrough: true, + }; + return m; + }); + assert.deepEqual(report.diagnostics, []); + assert.deepEqual(resolved.features, ['surface.window']); + const empty = await run((m) => { m.surface.window = {}; return m; }); + assert.deepEqual(empty.report.diagnostics, []); +}); + +test('miniapp: surface.window dimensions are integers from 200 to 10000', async () => { + for (const value of [200, 10000]) { + const ok = await run((m) => { m.surface.window = { width: value }; return m; }); + assert.deepEqual(ok.report.diagnostics, [], `width ${value}`); + } + for (const value of [199, 10001, 480.5, '480']) { + const bad = await run((m) => { m.surface.window = { minHeight: value }; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_SURFACE_INVALID'], `minHeight ${value}`); + } +}); + +test('miniapp: surface.window rejects unknown fields, non-booleans, min above size, and framed transparency', async () => { + const cases = [ + { title: 'Board' }, + { x: 10 }, + { resizable: 'yes' }, + { width: 400, minWidth: 401 }, + { height: 400, minHeight: 401 }, + { transparent: true }, + { transparent: true, frame: true }, + ]; + for (const window of cases) { + const { report } = await run((m) => { m.surface.window = window; return m; }); + assert.deepEqual(codes(report.diagnostics), ['MINIAPP_SURFACE_INVALID'], JSON.stringify(window)); + } + const notObject = await run((m) => { m.surface.window = [960, 700]; return m; }); + assert.deepEqual(codes(notObject.report.diagnostics), ['MINIAPP_SURFACE_INVALID']); + const equal = await run((m) => { m.surface.window = { width: 480, minWidth: 480 }; return m; }); + assert.deepEqual(equal.report.diagnostics, []); +}); + +test('miniapp: agentAccess allows only session.run and reports the feature', async () => { + const granted = await run((m) => { m.agentAccess = ['session.run']; return m; }); + assert.deepEqual(granted.report.diagnostics, []); + assert.deepEqual(granted.resolved.features, ['agentAccess']); + const denied = await run((m) => { m.agentAccess = []; return m; }); + assert.deepEqual(denied.report.diagnostics, []); + assert.deepEqual(denied.resolved.features, ['agentAccess']); + for (const value of [['session.run', 'session.read'], 'session.run', [null]]) { + const bad = await run((m) => { m.agentAccess = value; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_AGENT_ACCESS_INVALID'], JSON.stringify(value)); + } +}); + +test('miniapp: agentConcurrency is an integer from 1 to 32 and reports the feature', async () => { + for (const value of [1, 32]) { + const ok = await run((m) => { m.agentAccess = ['session.run']; m.agentConcurrency = value; return m; }); + assert.deepEqual(ok.report.diagnostics, [], `agentConcurrency ${value}`); + assert.deepEqual(ok.resolved.features, ['agentAccess', 'agentConcurrency']); + } + for (const value of [0, 33, 1.5, '4']) { + const bad = await run((m) => { m.agentConcurrency = value; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_AGENT_CONCURRENCY_INVALID'], `agentConcurrency ${value}`); + } +}); + +test('miniapp: routes under the Host-reserved /__mavis/ prefix are errors', async () => { + for (const route of ['/__mavis/health', '__mavis/app', '/__mavis']) { + const surface = await run((m) => { m.surface.path = route; return m; }); + assert.deepEqual(codes(surface.report.diagnostics), ['MINIAPP_ROUTE_RESERVED'], route); + } + const mcp = await run((m) => { m.mcpEndpoints = [{ server: 'a', path: '/__mavis/mcp' }]; return m; }); + assert.deepEqual(codes(mcp.report.diagnostics), ['MINIAPP_ROUTE_RESERVED']); + const lookalike = await run((m) => { m.surface.path = '/__mavisboard'; return m; }); + assert.deepEqual(lookalike.report.diagnostics, []); +}); + 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']); diff --git a/test/node.test.mjs b/test/node.test.mjs index acab22a..9e1c0b0 100644 --- a/test/node.test.mjs +++ b/test/node.test.mjs @@ -52,3 +52,34 @@ test('node: .js files skip the syntax check and rely on the pattern checks only' }); assert.deepEqual(diagnostics, []); }); + +test('node: https, http2, net, and tls servers are warnings because the loopback guard covers only node:http', async () => { + const sources = [ + ['a.mjs', "import https from 'node:https';\nexport const s = https.createServer({});\n"], + ['b.mjs', "import http2 from 'node:http2';\nexport const s = http2.createSecureServer({});\n"], + ['c.mjs', "import * as net from 'net';\nexport const s = net.createServer();\n"], + ['d.mjs', "import { createServer } from 'node:tls';\nexport const s = createServer({});\n"], + ['e.cjs', "const { createServer: make } = require('node:https');\nmodule.exports = make({});\n"], + ]; + for (const [file, source] of sources) { + const diagnostics = await run((dir) => writeFile(path.join(dir, 'miniapp', 'node', file), source)); + assert.deepEqual(codes(diagnostics), ['NODE_UNGUARDED_SERVER'], source); + assert.equal(diagnostics[0].level, 'warning'); + } +}); + +test('node: outbound https requests and node:http servers are not flagged', async () => { + const diagnostics = await run((dir) => + writeFile( + path.join(dir, 'miniapp', 'node', 'client.mjs'), + "import https from 'node:https';\nimport { createServer } from 'node:http';\nexport const get = (u) => https.get(u);\nexport const s = createServer();\n", + ), + ); + assert.deepEqual(diagnostics, []); +}); + +test('node: handling a /__mavis/ route is a warning', async () => { + const diagnostics = await run((dir) => appendFile(path.join(dir, ENTRY), "\nexport const reserved = '/__mavis/status';\n")); + assert.deepEqual(codes(diagnostics), ['NODE_RESERVED_ROUTE']); + assert.equal(diagnostics[0].level, 'warning'); +}); diff --git a/test/package.test.mjs b/test/package.test.mjs index ad25cb0..58894c3 100644 --- a/test/package.test.mjs +++ b/test/package.test.mjs @@ -4,7 +4,7 @@ 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'; +import { codes, copyExample, editJson, makeTmpRoot } from './helpers.mjs'; test('package: the example is a clean baseline', async () => { const root = await makeTmpRoot(); @@ -51,3 +51,18 @@ test('package: an example is not subject to the lowercase-author rule', async () const { dir } = await copyExample(root, { author: 'Examples' }); assert.deepEqual(await validatePackage(dir, { requireLowercaseAuthor: false }), []); }); + +test('package: features from plugin.json and miniapp.json reach the README minimum-version check', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await editJson(path.join(dir, 'miniapp', 'miniapp.json'), (m) => ({ ...m, agentAccess: ['session.run'] })); + await editJson(path.join(dir, '.minimax-plugin', 'plugin.json'), (m) => ({ ...m, agents: ['agents/helper.md'] })); + await mkdir(path.join(dir, 'agents')); + await writeFile(path.join(dir, 'agents', 'helper.md'), '# helper\n'); + const diagnostics = await validatePackage(dir); + assert.deepEqual(codes(diagnostics), ['README_MIN_VERSION_MISSING']); + assert.match(diagnostics[0].message, /agents, agentAccess/u); + const readme = path.join(dir, 'README.md'); + await writeFile(readme, (await readFile(readme, 'utf8')).replace('MiniMax Code 3.0.73', 'MiniMax Code 3.1.2')); + assert.deepEqual(await validatePackage(dir), []); +}); diff --git a/test/readme.test.mjs b/test/readme.test.mjs index 108aa07..71609d4 100644 --- a/test/readme.test.mjs +++ b/test/readme.test.mjs @@ -6,16 +6,18 @@ 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) { +async function run(transform, { features } = {}) { 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 }); + await checkReadme(report, { packageDir: dir, features }); return report.diagnostics; } +const TESTED_ON_3_0_73 = /- MiniMax Code 3\.0\.73 on macOS\./u; + test('readme: baseline has both headings', async () => { assert.deepEqual(await run(), []); }); @@ -40,3 +42,23 @@ test('readme: missing README is not reported here (layout owns it)', async () => await checkReadme(report, { packageDir: dir }); assert.deepEqual(report.diagnostics, []); }); + +test('readme: a feature that needs a newer client warns until Tested environment names that version', async () => { + const stale = await run(undefined, { features: ['agentAccess', 'surface.window'] }); + assert.deepEqual(codes(stale), ['README_MIN_VERSION_MISSING']); + assert.equal(stale[0].level, 'warning'); + assert.match(stale[0].message, /agentAccess, surface\.window/u); + assert.match(stale[0].message, /MiniMax Code 3\.1\.2/u); + for (const line of ['- MiniMax Code 3.1.2 on macOS.', '- MiniMax Code >= 3.2.0 on Windows 11.', '- MiniMax Code v3.10.0.']) { + assert.deepEqual(await run((text) => text.replace(TESTED_ON_3_0_73, line), { features: ['agentAccess'] }), [], line); + } +}); + +test('readme: only a MiniMax Code version inside Tested environment satisfies the minimum', async () => { + const otherRuntime = await run((text) => text.replace(TESTED_ON_3_0_73, '- MiniMax Code 3.1.1, Node 22.0.0.'), { features: ['agents'] }); + assert.deepEqual(codes(otherRuntime), ['README_MIN_VERSION_MISSING']); + const otherSection = await run((text) => text.replace('## Install', '## Install\n\nNeeds MiniMax Code 3.1.2.'), { features: ['agents'] }); + assert.deepEqual(codes(otherSection), ['README_MIN_VERSION_MISSING']); + const noSection = await run((text) => text.replace('## Tested environment', '## Tested'), { features: ['agents'] }); + assert.deepEqual(codes(noSection), ['README_HEADING_MISSING', 'README_MIN_VERSION_MISSING']); +}); From f1a915bc68721040f09f9040bdd49f2af005087c Mon Sep 17 00:00:00 2001 From: dazhi Date: Fri, 16 Oct 2026 17:35:40 +0800 Subject: [PATCH 2/2] docs: publish the MiniMax Code 3.1.2 Mini App contract - package-contract.md: surface.window, agentAccess, agentConcurrency, plugin.json agents, the /__mavis/ reservation, and a minimum client version table. - runtime.md: context.agent and the loopback authentication rules (serve the assigned port with node:http, keep /__mavis/ free, expect 401 after a restart until Refresh). - agent.md (new): the Agent grant, sessions, calls, interaction, events, and errors for Node and the page. - client.md (new): window.mcode.miniapp.message, agent, and the proposed window handle. - security.md: loopback rules and Agent turns (approvals stay with the user, untrusted input). - AGENTS.md, CONTRIBUTING, root READMEs: the new docs and the minimum version requirement. - hello-miniapp: miniapp-api.ts synced to the 3.1.2 Host snapshot, and client-api.ts added for page type checking. The example uses none of the new features, so its tested environment is unchanged. Assisted-by: Claude Opus 5.5 (OpenCode) --- AGENTS.md | 38 +- CONTRIBUTING.md | 4 +- CONTRIBUTING.zh-CN.md | 4 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/agent.md | 150 +++++++ docs/client.md | 97 +++++ docs/package-contract.md | 77 +++- docs/runtime.md | 33 +- docs/security.md | 23 +- examples/hello-miniapp/README.md | 1 + .../miniapp/client/client-api.ts | 387 ++++++++++++++++++ .../hello-miniapp/miniapp/node/miniapp-api.ts | 288 +++++++++++++ 13 files changed, 1079 insertions(+), 27 deletions(-) create mode 100644 docs/agent.md create mode 100644 docs/client.md create mode 100644 examples/hello-miniapp/miniapp/client/client-api.ts diff --git a/AGENTS.md b/AGENTS.md index 5592089..e4855fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,14 @@ update one package at `plugins///`. 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. + Node code reads or writes files, spawns processes, or makes network requests. Read + `docs/client.md` before the page calls `window.mcode`, and `docs/agent.md` before declaring + `agentAccess`. 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). + network hosts, spawned processes, Agent turns, where state is stored). When the package uses a + feature with a minimum client version (`docs/package-contract.md`), name a MiniMax Code version + at or above it under `## Tested environment`. 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. @@ -28,12 +32,16 @@ update one package at `plugins///`. - 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. +- Bind only `context.listen.host` / `context.listen.port`, with a `node:http` server (or a framework + on top of it); `https`, `http2`, `tls`, raw `net`, or a child process on that port never becomes + ready. Serve `surface.path` plus your own routes, none under `/__mavis/`. +- A Mini App package is a MiniMax Plugin. Skills, subagents (`agents`), 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, the page APIs, and the Agent API; treat `context.hostConnector` and the other + capabilities as outside its scope. +- `agentAccess` gives an Agent with files, shell, and MCP. Declare it only when needed, and never + answer a `permission.requested` with `allowOnce` unless the user chose it. - 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. @@ -43,13 +51,17 @@ update one package at `plugins///`. ## 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. +behaviour (files, network, processes, Agent turns, 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 +- `docs/package-contract.md` — layout, the three manifests, portable paths, limits, minimum client + versions +- `docs/runtime.md` — `start(context)`, `dispose`, logger, lifecycle, loopback authentication +- `docs/client.md` — page APIs: `message.append`, `agent`, `window` +- `docs/agent.md` — `agentAccess`, sessions, calls, events, errors +- `docs/security.md` — process boundary, state, writing outside `dataDir`, spawning, network, Agent + turns - `examples/hello-miniapp/` — the copyable starting point - `CONTRIBUTING.md` — fork, pull request, and license steps for people diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1fb04e..470185f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,8 @@ 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///`. Use a lowercase GitHub username for the author directory. -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. +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`, `docs/client.md`, `docs/agent.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, network requests, and any Agent turns it runs. Screenshots or GIFs are welcome. If the app uses a feature that needs a newer client, such as `agentAccess` or `surface.window`, name a MiniMax Code version at or above its minimum under `## Tested environment` (see "Minimum client version" in `docs/package-contract.md`). 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, run `npm run check` (Node.js 22 or later) until it reports no errors, and open a pull request. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 916f102..e109fba 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -5,8 +5,8 @@ 欢迎分享你为 MiniMax Code 制作的小工具、游戏和其他有趣作品。 1. Fork 本仓库,在 `plugins/<你的 GitHub username>//` 下放入完整插件包。用户名目录统一使用小写。 -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。 +2. 从 `examples/hello-miniapp/` 复制开始。保留 `.minimax-plugin/` 隐藏目录、`package.json`、`miniapp/` 和运行所需资源。插件应能独立复制使用,不依赖仓库里的其他目录。具体规则见 `docs/package-contract.md`、`docs/runtime.md`、`docs/client.md`、`docs/agent.md` 和 `docs/security.md`(英文)。 +3. 添加简短的英文 `README.md`,可另附 `README.zh-CN.md` 并互相链接。说明用途、安装与使用方式、已验证的客户端版本和系统,以及需要的配置、文件访问、网络请求和会运行的 Agent 任务。欢迎附上截图或 GIF。如果用到了需要较新客户端的能力(如 `agentAccess`、`surface.window`),请在 `## Tested environment` 中写明不低于最低要求的 MiniMax Code 版本(见 `docs/package-contract.md` 的 “Minimum client version”)。 4. 添加你有权使用的 `LICENSE`,保留第三方代码和素材要求的署名。 5. 在根目录中英文 README 的作品表格中各增加一行,运行 `npm run check`(需要 Node.js 22 或更高版本)直到没有 error,再提交 Pull Request。 diff --git a/README.md b/README.md index 1f85b31..31fc002 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The preview uses synthetic data. The app interface is currently in Chinese. See ### Requirements -Use a MiniMax Code desktop version that supports MiniApps. Check the app's README for tested client versions, operating systems, configuration, and known limitations. Compatibility is documented per app. +Use a MiniMax Code desktop version that supports MiniApps. Check the app's README for tested client versions, operating systems, configuration, and known limitations. Compatibility is documented per app. Apps that use newer capabilities, such as running Agent tasks or opening in their own window, need MiniMax Code 3.1.2 or later; older versions cannot install them. ### 1. Download diff --git a/README.zh-CN.md b/README.zh-CN.md index 43754de..883e1dc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -101,7 +101,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 ### 使用要求 -需要支持 MiniApp 的 MiniMax Code 桌面端版本。各作品的 README 会说明已验证的客户端版本、操作系统、所需配置和已知限制,兼容性以具体作品为准。 +需要支持 MiniApp 的 MiniMax Code 桌面端版本。各作品的 README 会说明已验证的客户端版本、操作系统、所需配置和已知限制,兼容性以具体作品为准。使用较新能力(如运行 Agent 任务、在独立窗口中打开)的作品需要 MiniMax Code 3.1.2 或更高版本,旧版本无法安装。 ### 1. 下载 diff --git a/docs/agent.md b/docs/agent.md new file mode 100644 index 0000000..39c69c4 --- /dev/null +++ b/docs/agent.md @@ -0,0 +1,150 @@ +# Mini App Agent API + +Verified against MiniMax Code 3.1.2. Earlier clients reject packages that declare `agentAccess`. + +A Mini App can run Agent turns from its Node entry (`context.agent`) or its page +(`window.mcode.miniapp.agent`). The Agent keeps its full capabilities: files, shell, Skills, and MCP, +including this Mini App's own MCP tools. Declare it only when the Mini App genuinely needs an Agent +at runtime; a page that only prepares text for the user to send should use +`window.mcode.miniapp.message.append` instead (see `docs/client.md`). + +## Grant + +```json +{ + "agentAccess": ["session.run"], + "agentConcurrency": 4 +} +``` + +- `agentAccess` in `miniapp/miniapp.json` is the only grant. Absent or `[]` means no access: + `context.agent` is absent and page calls reject with `not_permitted`. +- `agentConcurrency` (1–32, default 4) caps how many turns of this Mini App run at once. Each session + runs one turn at a time. Further `run` / `answer` calls wait in order, up to four times + `agentConcurrency`; beyond that they fail with `busy`. +- Changing either field restarts the Mini App process when the package is updated. +- State in the README `## Data & access` that the Mini App runs Agent turns and what it asks them + to do (see `docs/security.md`). + +## Sessions + +Each Mini App owns its sessions and can continue only those. They are hidden from the user's +session list. + +| | Page-initiated new session | Node-initiated new session | +| --- | --- | --- | +| Agent | The agent of the chat the Mini App is open in; the primary agent when it is not open in a chat. | The primary agent. | +| Working directory | That chat's project directory when it has one; otherwise this Mini App's own Agent directory. | This Mini App's own Agent directory. | +| Model | A snapshot of that chat's model and thinking level; the runtime default otherwise. | The runtime default. | + +- Pass `agent` (a `name` from `agents()`) to `create` or to the first `run` to start the session as + another agent. The alias `main` is rejected. A session never changes its agent; passing `agent` + when continuing is `INVALID_ARGUMENTS` / `invalid_request`. +- A continued session keeps its agent and directory. Its model is fixed at creation and does not + follow later changes in the chat; pass `model` to `run` or `answer` to switch it first. +- `create({ agent? })` makes a session without running a turn and returns `{ sessionId }`. A page can + create a session bound to its chat and hand the `sessionId` to its Node code to continue there. + +## Calls + +Node calls take an options object `{ signal?, onEvent? }`; page calls take `{ onEvent? }` and use +`requestId` with `cancel(requestId)` instead of a signal. + +| Call | Result | +| --- | --- | +| `run({ text, sessionId?, model?, agent? })` (page also `requestId?`) | `{ sessionId, turnId?, model?, status, text?, error?, pendingQuestion? }` when the turn ends. `status` is `finished`, `error`, or `aborted`; `text` is the final reply. `text` input is at most 100,000 UTF-16 code units. | +| `answer({ sessionId, answers, model? })` with `requestId` (Node) or `questionId` (page) naming the questionnaire | Same as `run`, for the continuation turn. | +| `reply({ sessionId, requestId, decision })` | Decides a waiting `permission.requested`; `decision` is `allowOnce` or `deny`. There is no "always allow". | +| `models()` | `{ models, default, host? }`: the models the user can use, the runtime default, and (page only) the chat's current model. `default` and `host` can be passed as `model` directly. | +| `agents()` | `{ agents }`: each `{ name, displayName, primary }`. | +| `create({ agent? })` | `{ sessionId }`, without running a turn. | +| `open(sessionId)` (page only) | Shows the session as a tab in the side panel of the chat the Mini App is open in, where the user can read it and answer its cards. Idempotent. | +| `cancel(requestId)` (page only) | Cancels that call; unknown or finished ids do nothing. | + +- A model is `{ providerId, modelId, variant?, thinking?: { effort } }`. Build it from a `models()` + entry; pick `variant` from `variants` and `effort` from `thinking.efforts`. `variant: ''` means + thinking is off and must be passed as is. +- Call `context.agent` from request handlers, never inside `start(context)`. +- Node allows 16 outstanding `run` / `answer` / `models` / `agents` / `create` requests (shared with + Host Connector requests) plus 16 for `reply`, so a full queue can still answer approvals. + +## Interaction + +Two interactions block a turn. The Mini App renders them itself, or lets the Host do it with the +page's `open(sessionId)`: + +- `permission.requested` → `reply`: a tool call waits for approval. +- `question.requested` → `answer`: the turn paused on a questionnaire (a question to the user, a + feature enable, or a plan review). The same questionnaire is in `result.pendingQuestion`, so a run + without `onEvent` can render it too. Call `answer` only after the paused `run` has resolved; an + earlier `answer` fails with `busy`. + +A permission request waits only while someone can answer it: the run that owns the session passed +`onEvent`, or the session is open in a chat side panel through `open`. Otherwise the Host denies it +and tells the model that nobody can answer. A run that passes `onEvent` must handle +`permission.requested`, or it waits until it is cancelled or the page closes; there is no timeout. +When the Agent delegates to a child session, the child's requests arrive with the child's +`sessionId` and `turnId`; reply with those and do not filter events by the root turn. + +```js +async function runWithApproval(agent, text, confirmTool, askUser) { + const onEvent = (event) => { + if (event.type !== 'permission.requested') return; + void confirmTool(event.toolName, event.reason) + .then((allowed) => + agent.reply({ sessionId: event.sessionId, requestId: event.requestId, decision: allowed ? 'allowOnce' : 'deny' }), + ) + .catch(() => {}); // request_not_found: the run was cancelled meanwhile + }; + let result = await agent.run({ text }, { onEvent }); + while (result.pendingQuestion) { + const answers = await askUser(result.pendingQuestion.request); + // Node names the questionnaire `requestId`; the page names it `questionId`. + result = await agent.answer( + { sessionId: result.sessionId, requestId: result.pendingQuestion.requestId, answers }, + { onEvent }, + ); + } + return result; +} +``` + +`answers` holds one `{ stepId, selectedOptionIds, selectedOther, otherText?, skipped? }` per +questionnaire step. + +## Events + +`onEvent` receives events in this vocabulary. Every event except `run.queued` carries `sessionId`. +Ignore any `type` you do not know; new types may be added. + +| Event | Meaning | +| --- | --- | +| `run.queued` | All slots are taken; `position` is the call's 1-based place in line. Sent once, before the turn. | +| `turn.started` | The turn began; `model` is the model it runs with. | +| `text.delta` | The next piece of the streaming reply. | +| `message.committed` | One complete assistant message; a turn can have several. | +| `tool.started` / `tool.finished` | The Agent called tool `name`; `ok` is `false` when it failed. | +| `permission.requested` | Tool `toolName` waits for `reply`; carries `requestId`, `reason`, `toolInput?`. | +| `question.requested` | The turn paused on a questionnaire; carries `requestId`, `mode`, `request`. | +| `turn.finished` | Always last; same `status`, `text?`, `error?` as the result. | + +## Errors + +Node errors carry `code` and `retryable`; page errors are an `Error` whose `message` is the code. + +| Node `code` | Page `message` | Meaning | +| --- | --- | --- | +| `NOT_PERMITTED` | `not_permitted` | No `agentAccess`, or (page `open`) the Mini App is not open in a chat. | +| `BUSY` | `busy` | The session already runs a turn, or the queue is full. Retryable. | +| `SESSION_NOT_FOUND` | `session_not_found` | Not a session this Mini App owns. | +| `INVALID_ARGUMENTS` | `invalid_request` | Malformed input, an unusable model, or `agent` on an existing session. | +| `AGENT_UNAVAILABLE` | `runtime_unavailable` | Nothing was started; retry later. Also returned to a page or process left over from before a restart. | +| `REQUEST_CANCELLED` | `cancelled` | Cancelled before anything ran. | +| `AGENT_OUTCOME_UNKNOWN` | `result_unknown` | The turn may have run. Never retry automatically; check the session first. | +| `REQUEST_NOT_FOUND` | `request_not_found` | The permission request or questionnaire is unknown or already settled. | + +A cancelled turn resolves with `status: "aborted"` only when the Host confirmed the abort. A process +restart cancels in-flight turns, and reloading or closing the page cancels the page's turns. + +The type declarations are in `examples/hello-miniapp/miniapp/node/miniapp-api.ts` (Node) and +`examples/hello-miniapp/miniapp/client/client-api.ts` (page). diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000..dbfc61e --- /dev/null +++ b/docs/client.md @@ -0,0 +1,97 @@ +# Mini App page APIs + +Verified against MiniMax Code 3.1.2. + +MiniMax Code injects `window.mcode.miniapp` into the Mini App's own page. Older clients, ordinary +Browser tabs, and other browsers do not provide it, so check that a method exists before calling +it. No SDK is needed. For type checking, copy `examples/hello-miniapp/miniapp/client/client-api.ts` +next to your Client source and reference it from TypeScript or JSDoc; never load it with a script +tag or import it at runtime. + +The bridge only works in the page's own top-level document on its own origin. Keep third-party +scripts out of that document: anything running there shares the page's access to the bridge. + +## `miniapp.message.append(text)` + +Asks the user to add plain text to the chat input of the conversation the Mini App is open in. The +Host shows its own confirmation with the plugin, the target conversation, and the text. After the +user confirms, the text is appended to the unsent draft; the user can still edit it and must press +Send. The page cannot send messages, read chat history, or change sent messages. + +```js +async function addToChat(text, status) { + const message = window.mcode?.miniapp?.message; + if (!message?.append) { + status.textContent = 'This version of MiniMax Code cannot add text to the chat input.'; + return; + } + try { + await message.append(text); + status.textContent = 'Added to the chat input. Review it there and press Send.'; + } catch (error) { + status.textContent = error?.message === 'user_cancelled' ? 'Cancelled.' : `Not added (${error?.message}).`; + } +} +``` + +- Call it from a user action, such as a button click. `text` is at most 100,000 UTF-16 code units + and is appended literally. Every call needs its own confirmation. +- Failures reject with an `Error` whose `message` is `user_cancelled`, `invalid_request`, + `text_too_large`, `not_allowed`, `target_unavailable` (no conversation to add to), or + `result_unknown`. After `result_unknown` the text may already be in the draft; never repeat the + call automatically. +- A resolved promise means the text reached the draft, not that the Agent started or replied. + +## `miniapp.agent` + +Runs Agent turns from the page. Present only on MiniMax Code 3.1.2 or later, and calls reject with +`not_permitted` unless `miniapp.json` declares `agentAccess`. The calls, sessions, events, and +errors are in `docs/agent.md`. + +## `miniapp.window` (proposed) + +Present on MiniMax Code 3.1.2 or later. **Proposed:** the shape may still change. Feature-detect +`window.mcode?.miniapp?.window` and do not rely on it long-term. + +The handle controls this Mini App's own window: whether the page sits in the side panel (`inline`) +or is popped out (`window`), and, when popped out, its position, size, and a few flags. + +```js +const handle = window.mcode?.miniapp?.window; +if (handle) { + const state = await handle.getState(); // { mode: 'inline' } or { mode: 'window', window: {...} } + button.addEventListener('click', () => { + void handle.update({ mode: 'window', width: 900, height: 650 }).catch(showError); + }); + const subscription = handle.onDidChangeState((next) => render(next)); + addEventListener('pagehide', () => subscription.dispose()); +} +``` + +- `id` is an opaque string that stays the same while the page pops out and back. +- `update(patch)` accepts `mode`, `width`, `height`, `x`, `y`, `visible`, `resizable`, + `alwaysOnTop`, `clickThrough`, and `skipTaskbar`; omitted fields are unchanged. `frame`, + `transparent`, `minWidth`, and `minHeight` can only be declared in `surface.window` + (`docs/package-contract.md`), and a patch containing them is rejected. +- Changing `mode`, and `focus()`, must come from a user gesture (for example, synchronously inside + a click handler); otherwise they reject with `not_permitted`. +- `alwaysOnTop: true` and `clickThrough: true` are allowed only when `surface.window` declares that + field `true`. `width` / `height` are rejected when the window is not resizable. `x` / `y` are + rejected when the window would not overlap any display. +- Several `update` calls in one animation frame are merged into one request and share its outcome. +- Failures reject with an `Error` whose `message` is `inline_mode` (only `mode` may change while + inline), `not_permitted`, `busy` (a `mode` change is already in progress), `invalid_request`, or + `result_unknown`. + +Pitfalls: + +- `frame: false` removes the title bar. Add a CSS `-webkit-app-region: drag` area so the user can + move the window. +- `transparent: true` also needs a transparent page background. The window has no native shadow; + use CSS `box-shadow` with transparent padding. +- The window does not resize with content. Keep `html, body { height: 100%; overflow: hidden }` and + scroll inside the page. +- Leaving `clickThrough` on makes the whole window, including its own controls, unclickable. Turn + it on only for the state that needs it. +- `update({ visible: false })` hides the window but the page keeps running. +- The window title is always the plugin's `displayName`. diff --git a/docs/package-contract.md b/docs/package-contract.md index 10c997b..faec38b 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -1,12 +1,14 @@ # Mini App package contract -Verified against MiniMax Code 3.0.73. +Verified against MiniMax Code 3.1.2. 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`. +`node_modules`, routes outside the Host-reserved `/__mavis/` prefix, the Node entry conventions in +`docs/runtime.md`, and a stated minimum client version for newer features (see +[Minimum client version](#minimum-client-version)). ## Layout @@ -24,6 +26,7 @@ a real image file behind `icon`, `lifecycle` limited to `on-demand`, no hard lin README.zh-CN.md Optional tests/ Optional; keep outside miniapp/ skills//SKILL.md Optional plugin capability + agents/.md Optional plugin capability (MiniMax Code 3.1.2 or later) *.mcp.json Optional plugin capability bindings/.binding.json Optional plugin capability ``` @@ -53,7 +56,9 @@ committed file. | `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. | +| `agents` | Optional array of `agents/.md` paths (`` matches `^[a-z][a-z0-9_-]*$`); each file must exist. Subagents the plugin adds; contents are validated at install time. Requires MiniMax Code 3.1.2 or later. | +| `hostCapabilities` | Rejected. Host capabilities come only from Host Bindings. | +| Any other field | Rejected. MiniMax Code 3.1.2 and later ignore unknown fields, but earlier clients reject the whole package. | Use `[]` for `apps`, `mcpServers`, and `skills` when the package has none. @@ -100,13 +105,79 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, 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. +- `surface.window` is optional; see [`surface.window`](#surfacewindow). - `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. +- No route (`surface.path` or an MCP endpoint `path`) may be `/__mavis` or start with `/__mavis/`. + The Host owns that prefix and answers some of it itself. - `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. +- `agentAccess` is optional. The only allowed value is `["session.run"]`, which lets the Mini App + run Agent turns; absent or `[]` means no Agent access. See `docs/agent.md` before declaring it. + Requires MiniMax Code 3.1.2 or later. +- `agentConcurrency` is optional: an integer from 1 to 32, default 4. It caps how many Agent turns + of this Mini App run at once; it has no effect without `agentAccess`. Requires MiniMax Code 3.1.2 + or later. + +### `surface.window` + +Optional. It declares the window MiniMax Code opens when the user pops the page out of the side +panel into its own window. Requires MiniMax Code 3.1.2 or later. + +```json +{ + "surface": { + "path": "/dashboard", + "window": { + "width": 960, + "height": 700, + "minWidth": 480, + "minHeight": 360, + "resizable": true, + "frame": true, + "transparent": false, + "alwaysOnTop": false, + "skipTaskbar": false, + "clickThrough": false + } + } +} +``` + +| Field | Rule | Default when omitted | +| --- | --- | --- | +| `width`, `height` | Integer from 200 to 10000. | The page's last size in the side panel. | +| `minWidth`, `minHeight` | Integer from 200 to 10000; not larger than `width` / `height` when both are given. | `480` / `360` | +| `resizable` | Boolean. | `true` | +| `frame` | Boolean. | `true` | +| `transparent` | Boolean. `true` requires `frame: false`. | `false` | +| `alwaysOnTop` | Boolean. Also the ceiling for turning it on from the page at runtime. | `false` | +| `skipTaskbar` | Boolean. | `false` | +| `clickThrough` | Boolean. Mouse events pass through to whatever is underneath; the page still receives mouse moves. Also the ceiling for turning it on from the page at runtime. | `false` | +| Any other field | Rejected. Title, position, parent window, and close behaviour are always set by the Host. | | + +The window never opens at launch: it opens when the user pops the page out, or when the page +switches to it from a user gesture through `window.mcode.miniapp.window` (see `docs/client.md`). +Closing it, or Cmd/Ctrl+W, returns the page to the side panel. + +## Minimum client version + +Clients older than the version below reject the whole package when it declares the feature, so +the package cannot be installed there at all. + +| Feature | Declared in | Minimum MiniMax Code | +| --- | --- | --- | +| `agents` | `plugin.json` | 3.1.2 | +| `agentAccess` | `miniapp/miniapp.json` | 3.1.2 | +| `agentConcurrency` | `miniapp/miniapp.json` | 3.1.2 | +| `surface.window` | `miniapp/miniapp.json` | 3.1.2 | + +When a package declares any of these, name a MiniMax Code version at or above the minimum under +`## Tested environment` in its README, for example `- MiniMax Code 3.1.2 on macOS 15`. +`npm run check` warns until it finds one there. ## Portable paths diff --git a/docs/runtime.md b/docs/runtime.md index 3e1064d..63fc2fc 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -1,6 +1,6 @@ # Mini App Node runtime -Verified against MiniMax Code 3.0.73. +Verified against MiniMax Code 3.1.2. ## Loading @@ -29,6 +29,7 @@ to your entry for editor type checking; it is never imported at runtime. | `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. | +| `agent` | Present only when `miniapp.json` declares `"agentAccess": ["session.run"]` (MiniMax Code 3.1.2 or later). See `docs/agent.md`. | ## `start(context)` @@ -37,6 +38,7 @@ to your entry for editor type checking; it is never imported at runtime. - 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. +- Do not call `context.agent` inside `start`; call it from a request handler. ## `dispose()` @@ -44,6 +46,33 @@ Close everything the entry started: the HTTP server, timers, child processes, st open file or database handles. The Host stops only the entry process; it does not discover or terminate processes the entry spawned. +## Loopback authentication + +From MiniMax Code 3.1.2, the Host guards the assigned port. Before it loads your entry, it installs a +check on every `node:http` server in the process. The Mini App page and the Host's MCP client send a +per-run credential; every other request gets `401` (WebSocket upgrades are closed) before your +handler runs, and the credential header is removed before a request reaches your code. After +`start` resolves, the Host confirms the check answers on the assigned port, and only then treats the +Mini App as ready. Your code needs no auth logic for this. + +- Serve `context.listen.port` with a `node:http` server: `createServer` from `node:http`, or a + framework built on it. Do not bind that port with `https`, `http2`, `tls`, a raw `net` server, + or a child process. Those are not covered, so the Mini App never becomes ready and startup fails + with a readiness timeout. A backend in another runtime (Python, Java) must listen on its own + internal port behind a proxy in the Node entry. `npm run check` warns when a Node file creates + an `https`, `http2`, `net`, or `tls` server. +- The check covers every `node:http` server in the process, not only the assigned port. A second + in-process server (for example an OAuth callback listener) also answers `401` to callers without + the credential, including third-party redirects. Accept such requests on the assigned port's own + handler instead. +- The `/__mavis/` path prefix belongs to the Host. Do not declare or handle routes under it. + `npm run check` rejects manifest routes there and warns when Node code mentions it. +- Build page requests from relative or same-origin URLs. Requests from a Service Worker, a popup, + an external browser, or an ordinary Browser tab carry no credential and get `401`. +- Do not read, forward, or log authentication headers. +- A restart issues a new credential. An already-open page keeps the old one and gets `401` until + the user clicks Refresh or opens the Mini App again. This is expected, not a plugin bug. + ## stdout and stdin Both belong to the Host. Never call `console.log`, `console.info`, `console.debug`, @@ -58,7 +87,7 @@ once, so assume yours can be stopped and restarted between two page views: keep ## 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. +only the routes your Node entry exposes, plus the page APIs in `docs/client.md`. ## Platforms diff --git a/docs/security.md b/docs/security.md index 0e562d9..1b517ce 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,11 +1,15 @@ # Mini App security rules -Verified against MiniMax Code 3.0.73. +Verified against MiniMax Code 3.1.2. ## 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. +- Bind only `context.listen.host` and `context.listen.port`, with a `node:http` server. Serve + `surface.path` and your own routes; do not proxy arbitrary URLs or arbitrary files. +- The Host authenticates every request to that port (see "Loopback authentication" in + `docs/runtime.md`). Do not add another scheme that expects or forwards the Host's credential, and + do not log request headers. +- Keep routes out of the Host-reserved `/__mavis/` prefix. - 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. @@ -30,3 +34,16 @@ place user input in a command line. ## Network Disclose every outbound host in the README. The default is none. + +## Agent turns + +An Agent run by a Mini App (`agentAccess`, see `docs/agent.md`) can read and write files, run +shell commands, and call MCP tools. + +- Declare `agentAccess` only when the Mini App needs it, and state in the README `## Data & access` + that it runs Agent turns and what it asks them to do. +- Leave approvals to the user. Show every `permission.requested` to the user and send `allowOnce` + only after they choose it, or `open` the session so they can decide in the chat side panel. Never + answer `allowOnce` automatically. +- Text from the page, files, or the network is untrusted. Build prompts from your own instructions + plus that text clearly marked as data, and validate anything the Agent returns before acting on it. diff --git a/examples/hello-miniapp/README.md b/examples/hello-miniapp/README.md index 836b505..17bce1d 100644 --- a/examples/hello-miniapp/README.md +++ b/examples/hello-miniapp/README.md @@ -31,6 +31,7 @@ Install section explains where that directory is). Restart MiniMax Code and ask package.json Mini App declaration miniapp/miniapp.json Payload roots, Node entry, page route miniapp/client/index.html The page served at /dashboard +miniapp/client/client-api.ts Type declarations for the page APIs (window.mcode); not loaded at runtime miniapp/node/server.mjs Node entry: start(context) → { dispose } miniapp/node/miniapp-api.ts Type declarations for the runtime context icon.png Plugin icon diff --git a/examples/hello-miniapp/miniapp/client/client-api.ts b/examples/hello-miniapp/miniapp/client/client-api.ts new file mode 100644 index 0000000..2ce7ab6 --- /dev/null +++ b/examples/hello-miniapp/miniapp/client/client-api.ts @@ -0,0 +1,387 @@ +/** + * Copyable Mini App Client declarations for TypeScript and JavaScript/JSDoc authoring. + * No Host implementation or runtime import: Electron preload provides window.mcode. + * Keep the .ts filename because Electron dependency packaging filters out .d.ts assets. + * Node start(context) uses miniapp-api.ts instead. See docs/client.md for usage and errors. + */ +export interface MiniAppMessageAPI { + /** + * Ask the user to append plain text to this Mini App's conversation input. + * Accepts at most 100,000 UTF-16 code units and preserves spaces, tabs, and newlines. + * An empty string still requires confirmation and leaves the draft unchanged. + * Resolves after the confirmed append; rejects on cancellation or failure. + * Does not send a message or insert, edit, or delete sent chat history. + */ + append(text: string): Promise; +} + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: { readonly [key: string]: unknown }; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model starts with. */ + readonly default: MiniAppAgentModelSelection; + /** The host chat's current model (the default when the chat has none); only when opened from a chat. */ + readonly host?: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +/** Final outcome of one Agent turn. */ +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed cancel rejects with `result_unknown`. */ + readonly status: 'finished' | 'error' | 'aborted'; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `permission.requested`: a tool call waits for approval; decide it with `agent.reply`. + * - `question.requested`: the turn paused on a questionnaire; continue with `agent.answer`. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + * Every event except `run.queued` names its `sessionId`, so `reply` works before the `run` that + * created the session has returned. `answer` does not: call it after the paused `run` resolves, + * or it rejects as busy. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: 'finished' | 'error' | 'aborted'; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunInput { + /** Plain text, at most 100,000 UTF-16 code units. */ + readonly text: string; + /** Continue a session this Mini App created earlier; omit to start a new one. */ + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a new + * session's model: it must be one `models()` lists; unusable is `invalid_request`, missing BYOK + * credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. A session + * never changes its agent, so passing it with `sessionId` is `invalid_request`. + */ + readonly agent?: string; + /** Your id for `cancel(requestId)`; 1-128 characters, unique among this page's active runs. */ + readonly requestId?: string; +} + +export interface MiniAppAgentReplyInput { + readonly sessionId: string; + /** `requestId` of the `permission.requested` event. */ + readonly requestId: string; + /** No `allowAlways`: a Mini App never writes the user's global rules. */ + readonly decision: 'allowOnce' | 'deny'; +} + +export interface MiniAppAgentAnswerInput { + readonly sessionId: string; + /** `requestId` of the `question.requested` event or of `result.pendingQuestion`. */ + readonly questionId: string; + /** One answer per questionnaire step. */ + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `MiniAppAgentRunInput.model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + /** Your id for `cancel(requestId)`, as in `run`. */ + readonly requestId?: string; +} + +export interface MiniAppAgentRunOptions { + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +/** + * Absent on older Hosts. Calls reject with `not_permitted` unless the manifest declares + * `agentAccess: ["session.run"]`. A new session uses the Agent of the chat this page is open in; + * a page without a host chat uses the primary Agent unless `run({ agent })` / `create({ agent })` + * names one from `agents()`. Continuing a session keeps its Agent. + * The Agent works in the host chat's project directory when it has one, otherwise in this Mini + * App's own Agent directory; a continued session keeps its directory. A new session takes the + * model and thinking level of the host chat (the runtime default when the chat has none) and keeps + * them; `run` / `answer` with `model` switch the session's model first. + * It runs one turn with that Agent's full capabilities (files, shell, Skills, MCP including this + * Mini App's own), and resolves when it ends. + * + * Interaction is yours to render: handle `permission.requested` with `reply` and + * `question.requested` with `answer`. A permission request waits only while this run passed + * `onEvent` or the session is open in a chat side panel through `open`; otherwise it is denied + * and the model is told why. A run with `onEvent` that does not handle the request waits until + * `cancel` or the page closes; there is no timeout. Up to `agentConcurrency` runs (default 4) per + * Mini App run at once, one per session; further `run` / `answer` calls wait in order (up to four + * times that many, see `run.queued`), and `cancel` on a waiting call rejects it with `cancelled`. + * `busy` means the session already has a run in progress or the wait queue is full. + * Failures reject with an `Error` whose `message` is `invalid_request`, `not_permitted`, `busy`, + * `session_not_found`, `runtime_unavailable`, `cancelled`, `result_unknown`, or + * `request_not_found`. `runtime_unavailable` means nothing was started; `result_unknown` means + * the turn may have run (or ended without the Host knowing whether a question is pending), so + * never repeat it automatically. + */ +export interface MiniAppAgentAPI { + run( + input: MiniAppAgentRunInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Decide a pending permission request; `request_not_found` when it is unknown or settled. */ + reply(input: MiniAppAgentReplyInput): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `result_unknown` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: MiniAppAgentAnswerInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Ask the Host to stop an active run or answer; unknown or finished ids are ignored. */ + cancel(requestId: string): void; + /** + * Show one of this Mini App's sessions as a tab in the side panel of the chat the + * Mini App is open in, where the user can read the conversation and answer + * permission and question cards themselves. Permission requests wait for the tab only + * while it stays open; once the user closes it, handle `permission.requested` yourself + * or call `open` again. Any session this Mini App owns can be opened, whichever chat + * created it; `session_not_found` otherwise. + */ + open(sessionId: string): Promise; + /** + * Returns the model catalog, runtime `default` and optional chat `host`. Pass `default` / `host` + * directly as `model` to `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, + * add a selected `variant` if needed and preserve `variant: ''`. If thinking is supported, + * select an effort from `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session bound to this chat (agent, project directory, model) without running a + * turn, and return its `sessionId`; continue it with `run({ sessionId })` here or hand it to + * Node code. `agent` picks the session's agent from `agents()`; a session never changes it. + * Takes no run slot. If the Host's 60 s deadline passes it rejects with `runtime_unavailable` + * although the session may already exist. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppClientAPI { + readonly message: MiniAppMessageAPI; + readonly agent?: MiniAppAgentAPI; + /** + * Proposed: shape may still change. Feature-detect with + * `window.mcode?.miniapp?.window` before use. + */ + readonly window?: MiniAppWindowHandle; +} + +/** Live state of the popped-out window: current position, size, and flags — not the manifest declaration. */ +export interface MiniAppWindowOptions { + readonly width: number; + readonly height: number; + readonly minWidth: number; + readonly minHeight: number; + readonly x: number; + readonly y: number; + readonly visible: boolean; + readonly resizable: boolean; + readonly frame: boolean; + readonly transparent: boolean; + readonly alwaysOnTop: boolean; + readonly clickThrough: boolean; + readonly skipTaskbar: boolean; +} + +export type MiniAppWindowState = + | { readonly mode: 'inline' } + | { readonly mode: 'window'; readonly window: MiniAppWindowOptions }; + +/** Every field is optional; omitted fields are left unchanged. */ +export type MiniAppWindowPatch = Partial<{ + mode: 'inline' | 'window'; + width: number; + height: number; + x: number; + y: number; + visible: boolean; + resizable: boolean; + alwaysOnTop: boolean; + clickThrough: boolean; + skipTaskbar: boolean; +}>; + +export interface MiniAppWindowDisposable { + dispose(): void; +} + +/** + * Page-side handle for this Mini App's own window (proposed). Separate from + * `message`; the Host injects both under the same `mcode.miniapp` namespace. + */ +export interface MiniAppWindowHandle { + /** Opaque and stable across pop-out and return; never a Session or launch identifier. */ + readonly id: string; + getState(): Promise; + /** + * `update({ mode: ... })` or `focus()` require the call to originate from a + * user gesture (`navigator.userActivation.isActive`); otherwise they reject + * with `not_permitted` before any Host round trip. + */ + update(patch: MiniAppWindowPatch): Promise; + focus(): Promise; + /** Returns a disposable; call `dispose()` to stop receiving updates. */ + onDidChangeState(listener: (state: MiniAppWindowState) => void): MiniAppWindowDisposable; +} + +export interface McodeClientAPI { + readonly miniapp: MiniAppClientAPI; +} + +declare global { + interface Window { + /** Absent outside a supported managed Mini App main page. Check before use. */ + readonly mcode?: McodeClientAPI; + } +} diff --git a/examples/hello-miniapp/miniapp/node/miniapp-api.ts b/examples/hello-miniapp/miniapp/node/miniapp-api.ts index 7a0e0fd..f5d8b7a 100644 --- a/examples/hello-miniapp/miniapp/node/miniapp-api.ts +++ b/examples/hello-miniapp/miniapp/node/miniapp-api.ts @@ -89,6 +89,293 @@ export interface MiniAppLogger { error(message: string, fields?: JsonObject): void; } +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** Set on every `BUSY`: the session already has a run in flight, or the wait queue is full. */ + readonly reason?: 'session_busy' | 'queue_full'; +} + +/** + * Present only when the manifest declares `agentAccess: ["session.run"]`. + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + export interface MiniAppLifecycle { dispose(): void | Promise; } @@ -101,6 +388,7 @@ export interface MiniAppContext { readonly signal: AbortSignal; readonly logger: MiniAppLogger; readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; } export interface MiniAppModule {