From ec66e78105952a9e52ff624dca320caa75eccb14 Mon Sep 17 00:00:00 2001 From: dazhi Date: Mon, 26 Oct 2026 18:04:14 +0800 Subject: [PATCH 1/3] feat(check): accept the MiniMax Code 3.1.5 manifest fields and flag calls the manifest does not grant The checker mirrors the 3.1.5 package reader (unchanged in 3.1.6): - miniapp.json accepts engines ({ "mcode": ">=MAJOR.MINOR.PATCH" }), toolAccess (only "call"), and deviceAccess (only "microphone"). - The minimum-version table registers toolAccess, deviceAccess, and engines at 3.1.5, and moves agentAccess "session.read" from 3.1.4 to 3.1.5: only 3.1.4 preview builds accepted it, the released 3.1.4 rejects it. An engines bound above that raises the version the README must name. ACCESS_UNDECLARED reports calls in the page or Node payload that need a grant the manifest does not make. The level follows the oldest client that installs the package: - context.agent / page agent calls without agentAccess; sessions(), observe({ all: true }), and the page's session() without "session.read"; context.tool / tool.request without toolAccess; and speech.transcribe without the microphone never work, so they are errors. - getUserMedia without the microphone worked before 3.1.5 and is denied from 3.1.5, so it is an error only when the package already needs 3.1.5 or later, and a warning otherwise. The payload walk moves from rules/node.mjs to fs.mjs so both rules share it; version comparison moves to version.mjs. Assisted-by: Claude Opus 5.5 (OpenCode) --- scripts/lib/contract.mjs | 21 ++++++- scripts/lib/fs.mjs | 32 ++++++++++- scripts/lib/package.mjs | 12 +++- scripts/lib/rules/miniapp.mjs | 42 ++++++++++++-- scripts/lib/rules/node.mjs | 28 +--------- scripts/lib/rules/readme.mjs | 22 +++----- scripts/lib/rules/usage.mjs | 61 +++++++++++++++++++++ scripts/lib/version.mjs | 19 +++++++ test/miniapp.test.mjs | 52 ++++++++++++++++++ test/package.test.mjs | 30 ++++++++++ test/readme.test.mjs | 20 ++++++- test/usage.test.mjs | 100 ++++++++++++++++++++++++++++++++++ 12 files changed, 385 insertions(+), 54 deletions(-) create mode 100644 scripts/lib/rules/usage.mjs create mode 100644 scripts/lib/version.mjs create mode 100644 test/usage.test.mjs diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs index 46d67a0e..2ccbfe54 100644 --- a/scripts/lib/contract.mjs +++ b/scripts/lib/contract.mjs @@ -1,18 +1,25 @@ // Constants shared by every rule. Values mirror the MiniMax Code package reader. -export const HOST_VERSION = '3.1.2'; +export const HOST_VERSION = '3.1.6'; // 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', - // agentAccess "session.read": page agent.session() / agent.sessions(); observed on 3.1.4 - 'agentAccess.session.read': '3.1.4', + // Released 3.1.4 still rejects "session.read"; only 3.1.4 preview builds accepted it. + 'agentAccess.session.read': '3.1.5', agentConcurrency: '3.1.2', + deviceAccess: '3.1.5', + engines: '3.1.5', 'surface.window': '3.1.2', + toolAccess: '3.1.5', }); +// From this version the Host denies Mini App pages every browser permission the manifest does +// not grant (the microphone included); earlier clients granted them silently. +export const BROWSER_PERMISSION_GATE_VERSION = '3.1.5'; + export const MANIFEST_FIELDS = new Set([ '$schema', 'schemaVersion', @@ -76,6 +83,9 @@ export const MINIAPP_FIELDS = new Set([ 'hostConnectorAccess', 'agentAccess', 'agentConcurrency', + 'engines', + 'toolAccess', + 'deviceAccess', ]); export const ARTIFACT_FIELDS = new Set(['client', 'node']); export const RUNTIME_FIELDS = new Set(['kind', 'entry', 'lifecycle']); @@ -86,6 +96,11 @@ export const SURFACE_WINDOW_DIMENSION_MIN = 200; export const SURFACE_WINDOW_DIMENSION_MAX = 10_000; export const AGENT_ACCESS_CAPABILITIES = new Set(['session.run', 'session.read']); export const AGENT_CONCURRENCY_MAX = 32; +export const TOOL_ACCESS_CAPABILITIES = new Set(['call']); +// Released clients accept only the microphone; later items get their own FEATURE_MIN_HOST_VERSION key. +export const DEVICE_ACCESS_ITEMS = new Set(['microphone']); +export const ENGINES_FIELDS = new Set(['mcode']); +export const ENGINES_REQUIREMENT = /^>=\s*(\d+)\.(\d+)\.(\d+)$/u; // 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']); diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs index 71e141e2..9af4f4d0 100644 --- a/scripts/lib/fs.mjs +++ b/scripts/lib/fs.mjs @@ -1,4 +1,5 @@ -import { readFile, stat } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; const BOM = '\uFEFF'; @@ -42,3 +43,32 @@ export async function readText(absolutePath) { export function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } + +// Package-relative paths of the files under `roots` (directories or single files) whose +// extension is in `extensions`, skipping node_modules. Sorted and unique. +export async function collectPayloadFiles(packageDir, roots, extensions) { + const files = new Set(); + for (const root of roots) { + const absolute = path.join(packageDir, ...root.split('/')); + if (await pathExists(absolute, 'directory')) { + for (const file of await walk(absolute, root, extensions)) files.add(file); + } else if (extensions.has(path.extname(root)) && (await pathExists(absolute, 'file'))) { + files.add(root); + } + } + return [...files].sort(); +} + +async function walk(absoluteDir, relativeDir, extensions) { + const files = []; + for (const dirent of await readdir(absoluteDir, { withFileTypes: true })) { + const relativePath = `${relativeDir}/${dirent.name}`; + if (dirent.isDirectory()) { + if (dirent.name.toLowerCase() === 'node_modules') continue; + files.push(...(await walk(path.join(absoluteDir, dirent.name), relativePath, extensions))); + } else if (dirent.isFile() && extensions.has(path.extname(dirent.name))) { + files.push(relativePath); + } + } + return files; +} diff --git a/scripts/lib/package.mjs b/scripts/lib/package.mjs index 34e12fb5..bc4c6449 100644 --- a/scripts/lib/package.mjs +++ b/scripts/lib/package.mjs @@ -9,6 +9,8 @@ import { checkMcode } from './rules/mcode.mjs'; import { checkMiniApp } from './rules/miniapp.mjs'; import { checkNode } from './rules/node.mjs'; import { checkReadme } from './rules/readme.mjs'; +import { checkAccessUsage } from './rules/usage.mjs'; +import { requiredHostVersion } from './version.mjs'; export async function validatePackage(packageDir, { requireLowercaseAuthor = true } = {}) { const dir = path.resolve(packageDir); @@ -30,13 +32,19 @@ 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: [], features: [] }; + let resolved = { nodeRoots: [], clientRoots: [], features: [], access: { agent: [], tool: false, microphone: false } }; 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, features }); + await checkReadme(report, { packageDir: dir, features, engines: resolved.engines }); await checkNode(report, { packageDir: dir, entry: resolved.entry, nodeRoots: resolved.nodeRoots }); + await checkAccessUsage(report, { + packageDir: dir, + roots: [...resolved.clientRoots, ...resolved.nodeRoots], + access: resolved.access, + minimum: requiredHostVersion(features, resolved.engines), + }); return report.diagnostics; } diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index 11eae5d8..413cfe06 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -5,6 +5,9 @@ import { AGENT_CONCURRENCY_MAX, ARTIFACT_FIELDS, CONNECTOR_PROVIDER, + DEVICE_ACCESS_ITEMS, + ENGINES_FIELDS, + ENGINES_REQUIREMENT, ENTRY_EXTENSION, EXCLUDED_DIRECTORY, HOST_CONNECTOR_ACCESS_FIELDS, @@ -19,6 +22,7 @@ import { SURFACE_WINDOW_DIMENSION_FIELDS, SURFACE_WINDOW_DIMENSION_MAX, SURFACE_WINDOW_DIMENSION_MIN, + TOOL_ACCESS_CAPABILITIES, } from '../contract.mjs'; import { isRecord, pathExists } from '../fs.mjs'; import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.mjs'; @@ -26,7 +30,7 @@ import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.m const FILE = 'miniapp/miniapp.json'; export async function checkMiniApp(report, { packageDir, manifest }) { - const resolved = { nodeRoots: [], clientRoots: [], features: [] }; + const resolved = { nodeRoots: [], clientRoots: [], features: [], access: { agent: [], tool: false, microphone: false } }; if (!isRecord(manifest)) { report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE); return resolved; @@ -44,12 +48,21 @@ export async function checkMiniApp(report, { packageDir, manifest }) { checkSurface(report, manifest.surface); checkMcpEndpoints(report, manifest.mcpEndpoints); checkHostConnectorAccess(report, manifest.hostConnectorAccess); - checkAgentAccess(report, manifest.agentAccess); + const agent = checkAccessList(report, manifest.agentAccess, 'agentAccess', AGENT_ACCESS_CAPABILITIES, 'MINIAPP_AGENT_ACCESS_INVALID'); checkAgentConcurrency(report, manifest.agentConcurrency); + const tool = checkAccessList(report, manifest.toolAccess, 'toolAccess', TOOL_ACCESS_CAPABILITIES, 'MINIAPP_TOOL_ACCESS_INVALID'); + const device = checkAccessList(report, manifest.deviceAccess, 'deviceAccess', DEVICE_ACCESS_ITEMS, 'MINIAPP_DEVICE_ACCESS_INVALID'); + const engines = checkEngines(report, manifest.engines); + if (engines) resolved.engines = engines; + resolved.access = { agent, tool: tool.includes('call'), microphone: device.includes('microphone') }; + if (manifest.agentAccess !== undefined) resolved.features.push('agentAccess'); if (Array.isArray(manifest.agentAccess) && manifest.agentAccess.includes('session.read')) resolved.features.push('agentAccess.session.read'); if (manifest.agentConcurrency !== undefined) resolved.features.push('agentConcurrency'); if (isRecord(manifest.surface) && manifest.surface.window !== undefined) resolved.features.push('surface.window'); + for (const field of ['engines', 'toolAccess', 'deviceAccess']) { + if (manifest[field] !== undefined) resolved.features.push(field); + } return resolved; } @@ -217,11 +230,14 @@ 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); +// Returns the declared values when the list is valid, otherwise [] (nothing is granted). +function checkAccessList(report, value, label, allowed, code) { + if (value === undefined) return []; + if (!Array.isArray(value) || value.some((item) => !allowed.has(item))) { + report.error(code, `${label} must be an array containing only ${[...allowed].map((c) => `"${c}"`).join(', ')}`, FILE); + return []; } + return [...new Set(value)].sort(); } function checkAgentConcurrency(report, value) { @@ -230,3 +246,17 @@ function checkAgentConcurrency(report, value) { report.error('MINIAPP_AGENT_CONCURRENCY_INVALID', `agentConcurrency must be an integer from 1 to ${AGENT_CONCURRENCY_MAX}`, FILE); } } + +// Returns the bound as "MAJOR.MINOR.PATCH". +function checkEngines(report, value) { + if (value === undefined) return undefined; + const match = + isRecord(value) && Object.keys(value).every((k) => ENGINES_FIELDS.has(k)) && typeof value.mcode === 'string' + ? ENGINES_REQUIREMENT.exec(value.mcode.trim()) + : null; + if (!match) { + report.error('MINIAPP_ENGINES_INVALID', 'engines must be { "mcode": ">=MAJOR.MINOR.PATCH" }', FILE); + return undefined; + } + return match.slice(1, 4).map(Number).join('.'); +} diff --git a/scripts/lib/rules/node.mjs b/scripts/lib/rules/node.mjs index 63273665..82796189 100644 --- a/scripts/lib/rules/node.mjs +++ b/scripts/lib/rules/node.mjs @@ -1,24 +1,16 @@ import { execFile } from 'node:child_process'; -import { readdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { RESERVED_ROUTE_LITERAL, START_EXPORT, STDOUT_CALL, UNGUARDED_SERVER } from '../contract.mjs'; -import { pathExists } from '../fs.mjs'; +import { collectPayloadFiles, pathExists } from '../fs.mjs'; const execFileAsync = promisify(execFile); const SCRIPT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); export async function checkNode(report, { packageDir, entry, nodeRoots }) { - const scripts = new Set(); - for (const root of nodeRoots) { - const absolute = path.join(packageDir, ...root.split('/')); - if (await pathExists(absolute, 'directory')) { - for (const file of await collectScripts(absolute, root)) scripts.add(file); - } else if (SCRIPT_EXTENSIONS.has(path.extname(root)) && (await pathExists(absolute, 'file'))) { - scripts.add(root); - } - } + const scripts = new Set(await collectPayloadFiles(packageDir, nodeRoots, SCRIPT_EXTENSIONS)); if (entry) scripts.add(entry); for (const relativePath of [...scripts].sort()) { @@ -51,20 +43,6 @@ export async function checkNode(report, { packageDir, entry, nodeRoots }) { } } -async function collectScripts(absoluteDir, relativeDir) { - const files = []; - for (const dirent of await readdir(absoluteDir, { withFileTypes: true })) { - const relativePath = `${relativeDir}/${dirent.name}`; - if (dirent.isDirectory()) { - if (dirent.name.toLowerCase() === 'node_modules') continue; - files.push(...(await collectScripts(path.join(absoluteDir, dirent.name), relativePath))); - } else if (dirent.isFile() && SCRIPT_EXTENSIONS.has(path.extname(dirent.name))) { - files.push(relativePath); - } - } - return files; -} - function firstLine(text) { return typeof text === 'string' ? text.split(/\r?\n/u).find((line) => line.trim()) ?? '' : ''; } diff --git a/scripts/lib/rules/readme.mjs b/scripts/lib/rules/readme.mjs index 3e4f79a9..469266d9 100644 --- a/scripts/lib/rules/readme.mjs +++ b/scripts/lib/rules/readme.mjs @@ -1,12 +1,13 @@ import path from 'node:path'; -import { FEATURE_MIN_HOST_VERSION, README_HEADINGS } from '../contract.mjs'; +import { README_HEADINGS } from '../contract.mjs'; import { readText } from '../fs.mjs'; +import { compareVersions, requiredHostVersion } from '../version.mjs'; 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 = [] }) { +export async function checkReadme(report, { packageDir, features = [], engines }) { const text = await readText(path.join(packageDir, 'README.md')); if (text === undefined) return; const lines = text.split(/\r?\n/u); @@ -16,12 +17,12 @@ export async function checkReadme(report, { packageDir, features = [] }) { report.warning('README_HEADING_MISSING', `README.md should contain the heading "${heading}"`, 'README.md'); } } - checkMinimumVersion(report, lines, features); + checkMinimumVersion(report, lines, features, engines); } -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)); +function checkMinimumVersion(report, lines, features, engines) { + const required = requiredHostVersion(features, engines); + if (required === undefined) return; const tested = testedEnvironmentSection(lines); const satisfied = [...tested.matchAll(HOST_VERSION_MENTION)].some((match) => compareVersions(match.slice(1, 4).join('.'), required) >= 0); if (!satisfied) { @@ -40,12 +41,3 @@ function testedEnvironmentSection(lines) { 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/scripts/lib/rules/usage.mjs b/scripts/lib/rules/usage.mjs new file mode 100644 index 00000000..75f376c4 --- /dev/null +++ b/scripts/lib/rules/usage.mjs @@ -0,0 +1,61 @@ +import path from 'node:path'; + +import { BROWSER_PERMISSION_GATE_VERSION } from '../contract.mjs'; +import { collectPayloadFiles, readText } from '../fs.mjs'; +import { compareVersions } from '../version.mjs'; + +// Files that run in the page or in Node. Declaration files (.ts) never run and are skipped. +const SCANNED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.html', '.htm']); + +// Mini App APIs that never work without their declaration, on any client. +const API_RULES = [ + { + pattern: /\b(?:context|miniapp)\??\.agent\??\.(?:run|answer|reply|cancel|open|models|agents|create|observe|sessions|session)\s*\(/u, + granted: (access) => access.agent.length > 0, + message: 'calls the Agent API but miniapp.json declares no agentAccess; context.agent is absent and page calls reject with not_permitted', + }, + { + pattern: /\bagent\??\.sessions\s*\(|\bagent\??\.observe\s*\(\s*\{\s*all\s*:\s*(?:true|!0)|\bagent\??\.session\s*\(\s*\)/u, + granted: (access) => access.agent.includes('session.read'), + message: 'lists sessions, observes every session, or reads the chat the page is open in, which needs agentAccess "session.read"; without it the call rejects with not_permitted', + }, + { + pattern: /\b(?:context|miniapp)\??\.tool\??\.(?:call|list|request)\s*\(/u, + granted: (access) => access.tool, + message: 'calls the tool API but miniapp.json does not declare toolAccess ["call"]; context.tool is absent and page tool.request rejects', + }, + { + pattern: /\bspeech\??\.transcribe\s*\(/u, + granted: (access) => access.microphone, + message: 'calls speech.transcribe but miniapp.json does not declare deviceAccess ["microphone"]; every call rejects with not_permitted', + }, +]; + +const GET_USER_MEDIA = /\bgetUserMedia\s*\(/u; + +export async function checkAccessUsage(report, { packageDir, roots, access, minimum }) { + const gated = minimum !== undefined && compareVersions(minimum, BROWSER_PERMISSION_GATE_VERSION) >= 0; + for (const relativePath of await collectPayloadFiles(packageDir, roots, SCANNED_EXTENSIONS)) { + const source = await readText(path.join(packageDir, ...relativePath.split('/'))); + if (source === undefined) continue; + for (const rule of API_RULES) { + if (!rule.granted(access) && rule.pattern.test(source)) report.error('ACCESS_UNDECLARED', rule.message, relativePath); + } + if (!access.microphone && GET_USER_MEDIA.test(source)) reportGetUserMedia(report, relativePath, gated); + } +} + +// Clients before the gate granted the microphone silently, so the call fails on every client +// that installs the package only when the package already needs a gated client. +function reportGetUserMedia(report, relativePath, gated) { + const base = `calls getUserMedia but miniapp.json does not declare deviceAccess ["microphone"]; MiniMax Code ${BROWSER_PERMISSION_GATE_VERSION} and later deny it with NotAllowedError`; + if (gated) { + report.error('ACCESS_UNDECLARED', `${base}, and this package needs ${BROWSER_PERMISSION_GATE_VERSION} or later`, relativePath); + } else { + report.warning( + 'ACCESS_UNDECLARED', + `${base}; it works only on clients before ${BROWSER_PERMISSION_GATE_VERSION}. Declaring deviceAccess makes the package need ${BROWSER_PERMISSION_GATE_VERSION} or later`, + relativePath, + ); + } +} diff --git a/scripts/lib/version.mjs b/scripts/lib/version.mjs new file mode 100644 index 00000000..bdd452f6 --- /dev/null +++ b/scripts/lib/version.mjs @@ -0,0 +1,19 @@ +import { FEATURE_MIN_HOST_VERSION } from './contract.mjs'; + +// Compares "MAJOR.MINOR.PATCH" strings numerically. +export 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; +} + +// The oldest MiniMax Code that installs the package: the highest minimum among its declared +// features and its engines.mcode bound. Undefined when nothing raises it. +export function requiredHostVersion(features, engines) { + const versions = features.map((feature) => FEATURE_MIN_HOST_VERSION[feature]); + if (engines) versions.push(engines); + return versions.reduce((max, version) => (max === undefined || compareVersions(version, max) > 0 ? version : max), undefined); +} diff --git a/test/miniapp.test.mjs b/test/miniapp.test.mjs index 08de4b44..1cadd8c9 100644 --- a/test/miniapp.test.mjs +++ b/test/miniapp.test.mjs @@ -26,6 +26,7 @@ test('miniapp: baseline passes and resolves entry and roots', async () => { nodeRoots: ['miniapp/node'], clientRoots: ['miniapp/client'], features: [], + access: { agent: [], tool: false, microphone: false }, }); }); @@ -179,6 +180,57 @@ test('miniapp: routes under the Host-reserved /__mavis/ prefix are errors', asyn assert.deepEqual(lookalike.report.diagnostics, []); }); +test('miniapp: engines.mcode is a ">=MAJOR.MINOR.PATCH" bound and reports the feature', async () => { + for (const mcode of ['>=3.2.0', '>= 3.1.5', ' >=10.0.12 ']) { + const { report, resolved } = await run((m) => { m.engines = { mcode }; return m; }); + assert.deepEqual(report.diagnostics, [], mcode); + assert.deepEqual(resolved.features, ['engines']); + assert.equal(resolved.engines, mcode.trim().replace(/^>=\s*/u, '')); + } + for (const engines of [{ mcode: '3.2.0' }, { mcode: '>3.2.0' }, { mcode: '>=3.2' }, { mcode: '^3.2.0' }, { mcode: '>=3.2.0-beta.1' }, { mcode: '>=3.2.0', node: '>=22' }, {}, '>=3.2.0']) { + const { report, resolved } = await run((m) => { m.engines = engines; return m; }); + assert.deepEqual(codes(report.diagnostics), ['MINIAPP_ENGINES_INVALID'], JSON.stringify(engines)); + assert.equal(resolved.engines, undefined); + } +}); + +test('miniapp: toolAccess allows only "call", reports the feature, and grants tool access', async () => { + const granted = await run((m) => { m.toolAccess = ['call']; return m; }); + assert.deepEqual(granted.report.diagnostics, []); + assert.deepEqual(granted.resolved.features, ['toolAccess']); + assert.equal(granted.resolved.access.tool, true); + const empty = await run((m) => { m.toolAccess = []; return m; }); + assert.deepEqual(empty.report.diagnostics, []); + assert.deepEqual(empty.resolved.features, ['toolAccess']); + assert.equal(empty.resolved.access.tool, false); + for (const value of [['mcp'], { mcp: ['notes'] }, 'call', [null]]) { + const bad = await run((m) => { m.toolAccess = value; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_TOOL_ACCESS_INVALID'], JSON.stringify(value)); + assert.equal(bad.resolved.access.tool, false); + } +}); + +test('miniapp: deviceAccess allows only "microphone", reports the feature, and grants the microphone', async () => { + const granted = await run((m) => { m.deviceAccess = ['microphone']; return m; }); + assert.deepEqual(granted.report.diagnostics, []); + assert.deepEqual(granted.resolved.features, ['deviceAccess']); + assert.equal(granted.resolved.access.microphone, true); + const empty = await run((m) => { m.deviceAccess = []; return m; }); + assert.deepEqual(empty.report.diagnostics, []); + assert.equal(empty.resolved.access.microphone, false); + for (const value of [['camera'], ['notifications'], ['geolocation'], 'microphone']) { + const bad = await run((m) => { m.deviceAccess = value; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_DEVICE_ACCESS_INVALID'], JSON.stringify(value)); + } +}); + +test('miniapp: access lists only the valid agentAccess values', async () => { + const both = await run((m) => { m.agentAccess = ['session.read', 'session.run']; return m; }); + assert.deepEqual(both.resolved.access.agent, ['session.read', 'session.run']); + const bad = await run((m) => { m.agentAccess = ['session.run', 'session.write']; return m; }); + assert.deepEqual(bad.resolved.access.agent, []); +}); + 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/package.test.mjs b/test/package.test.mjs index 58894c32..e1c0cbfe 100644 --- a/test/package.test.mjs +++ b/test/package.test.mjs @@ -66,3 +66,33 @@ test('package: features from plugin.json and miniapp.json reach the README minim await writeFile(readme, (await readFile(readme, 'utf8')).replace('MiniMax Code 3.0.73', 'MiniMax Code 3.1.2')); assert.deepEqual(await validatePackage(dir), []); }); + +test('package: getUserMedia without the microphone turns from warning to error once the package needs 3.1.5', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await writeFile(path.join(dir, 'miniapp', 'client', 'record.js'), 'export const open = () => navigator.mediaDevices.getUserMedia({ audio: true });\n'); + const anyVersion = await validatePackage(dir); + assert.deepEqual(codes(anyVersion), ['ACCESS_UNDECLARED']); + assert.equal(anyVersion[0].level, 'warning'); + + await editJson(path.join(dir, 'miniapp', 'miniapp.json'), (m) => ({ ...m, agentAccess: ['session.run', 'session.read'] })); + const gated = await validatePackage(dir); + assert.deepEqual(codes(gated, 'error'), ['ACCESS_UNDECLARED']); + assert.deepEqual(codes(gated, 'warning'), ['README_MIN_VERSION_MISSING']); + + await editJson(path.join(dir, 'miniapp', 'miniapp.json'), (m) => ({ ...m, deviceAccess: ['microphone'] })); + const readme = path.join(dir, 'README.md'); + await writeFile(readme, (await readFile(readme, 'utf8')).replace('MiniMax Code 3.0.73', 'MiniMax Code 3.1.5')); + assert.deepEqual(await validatePackage(dir), []); +}); + +test('package: an engines bound decides the minimum version for both the README and getUserMedia', async () => { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + await editJson(path.join(dir, 'miniapp', 'miniapp.json'), (m) => ({ ...m, engines: { mcode: '>=3.2.0' } })); + await writeFile(path.join(dir, 'miniapp', 'client', 'record.js'), 'navigator.mediaDevices.getUserMedia({ audio: true });\n'); + const diagnostics = await validatePackage(dir); + assert.deepEqual(codes(diagnostics, 'error'), ['ACCESS_UNDECLARED']); + const [readme] = diagnostics.filter((d) => d.code === 'README_MIN_VERSION_MISSING'); + assert.match(readme.message, /MiniMax Code 3\.2\.0/u); +}); diff --git a/test/readme.test.mjs b/test/readme.test.mjs index 71609d4d..ce3f6f40 100644 --- a/test/readme.test.mjs +++ b/test/readme.test.mjs @@ -6,13 +6,13 @@ 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, { features } = {}) { +async function run(transform, { features, engines } = {}) { 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, features }); + await checkReadme(report, { packageDir: dir, features, engines }); return report.diagnostics; } @@ -62,3 +62,19 @@ test('readme: only a MiniMax Code version inside Tested environment satisfies th const noSection = await run((text) => text.replace('## Tested environment', '## Tested'), { features: ['agents'] }); assert.deepEqual(codes(noSection), ['README_HEADING_MISSING', 'README_MIN_VERSION_MISSING']); }); + +test('readme: session.read, toolAccess, deviceAccess, and engines need 3.1.5; a 3.1.4 preview build is not enough', async () => { + for (const feature of ['agentAccess.session.read', 'toolAccess', 'deviceAccess', 'engines']) { + const preview = await run((text) => text.replace(TESTED_ON_3_0_73, '- MiniMax Code 3.1.4-preview.4773 on macOS.'), { features: [feature] }); + assert.deepEqual(codes(preview), ['README_MIN_VERSION_MISSING'], feature); + assert.match(preview[0].message, /MiniMax Code 3\.1\.5/u); + assert.deepEqual(await run((text) => text.replace(TESTED_ON_3_0_73, '- MiniMax Code 3.1.5 on macOS.'), { features: [feature] }), [], feature); + } +}); + +test('readme: an engines bound above the feature minimum raises the version the README must name', async () => { + const below = await run((text) => text.replace(TESTED_ON_3_0_73, '- MiniMax Code 3.1.6 on macOS.'), { features: ['engines'], engines: '3.2.0' }); + assert.deepEqual(codes(below), ['README_MIN_VERSION_MISSING']); + assert.match(below[0].message, /MiniMax Code 3\.2\.0/u); + assert.deepEqual(await run((text) => text.replace(TESTED_ON_3_0_73, '- MiniMax Code 3.2.0 on macOS.'), { features: ['engines'], engines: '3.2.0' }), []); +}); diff --git a/test/usage.test.mjs b/test/usage.test.mjs new file mode 100644 index 00000000..73eb9090 --- /dev/null +++ b/test/usage.test.mjs @@ -0,0 +1,100 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { checkAccessUsage } from '../scripts/lib/rules/usage.mjs'; +import { createReport } from '../scripts/lib/report.mjs'; +import { codes, copyExample, makeTmpRoot } from './helpers.mjs'; + +const NONE = { agent: [], tool: false, microphone: false }; +const ROOTS = ['miniapp/client', 'miniapp/node']; + +async function run(files, { access = NONE, minimum } = {}) { + const root = await makeTmpRoot(); + const { dir } = await copyExample(root); + for (const [relativePath, source] of Object.entries(files)) { + const file = path.join(dir, ...relativePath.split('/')); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, source); + } + const report = createReport(); + await checkAccessUsage(report, { packageDir: dir, roots: ROOTS, access, minimum }); + return report.diagnostics; +} + +test('usage: the example declares nothing and calls nothing', async () => { + assert.deepEqual(await run({}), []); +}); + +test('usage: Agent calls without agentAccess are errors on the page and in Node', async () => { + const page = await run({ 'miniapp/client/app.js': 'await window.mcode?.miniapp?.agent?.run({ text });\n' }); + assert.deepEqual(codes(page, 'error'), ['ACCESS_UNDECLARED']); + assert.match(page[0].message, /agentAccess/u); + const node = await run({ 'miniapp/node/agent.mjs': 'export const go = (context) => context.agent.create({});\n' }); + assert.deepEqual(codes(node, 'error'), ['ACCESS_UNDECLARED']); + assert.deepEqual(await run({ 'miniapp/node/agent.mjs': 'export const go = (context) => context.agent.run({ text: "x" });\n' }, { access: { ...NONE, agent: ['session.run'] } }), []); +}); + +test('usage: listing sessions, observing every session, and reading the page chat need session.read', async () => { + const runOnly = { ...NONE, agent: ['session.run'] }; + for (const source of [ + 'const { sessions } = await agent.sessions();\n', + 'agent.observe({ all: true }, { onEvent });\n', + 'agent?.observe({all:!0},{onEvent:e});\n', + 'const chat = await window.mcode.miniapp.agent.session();\n', + ]) { + const diagnostics = await run({ 'miniapp/client/app.js': source }, { access: runOnly }); + assert.deepEqual(codes(diagnostics, 'error'), ['ACCESS_UNDECLARED'], source); + assert.match(diagnostics[0].message, /session\.read/u); + assert.deepEqual(await run({ 'miniapp/client/app.js': source }, { access: { ...NONE, agent: ['session.read', 'session.run'] } }), [], source); + } + for (const source of ['agent.observe({ sessionId }, { onEvent });\n', 'await agent.session(ownSessionId);\n']) { + assert.deepEqual(await run({ 'miniapp/client/app.js': source }, { access: runOnly }), [], source); + } +}); + +test('usage: tool calls without toolAccess are errors', async () => { + for (const source of ['await context.tool.call(name, {});\n', 'const { tools } = await context.tool?.list();\n', 'await window.mcode.miniapp.tool.request([name]);\n']) { + const diagnostics = await run({ 'miniapp/node/tools.mjs': source }); + assert.deepEqual(codes(diagnostics, 'error'), ['ACCESS_UNDECLARED'], source); + assert.match(diagnostics[0].message, /toolAccess/u); + assert.deepEqual(await run({ 'miniapp/node/tools.mjs': source }, { access: { ...NONE, tool: true } }), [], source); + } +}); + +test('usage: speech.transcribe without the microphone is an error on every version', async () => { + const diagnostics = await run({ 'miniapp/client/index.html': '\n' }); + assert.deepEqual(codes(diagnostics, 'error'), ['ACCESS_UNDECLARED']); + assert.match(diagnostics[0].message, /deviceAccess/u); + assert.deepEqual(await run({ 'miniapp/client/index.html': '\n' }, { access: { ...NONE, microphone: true } }), []); +}); + +test('usage: getUserMedia without the microphone is an error only when every client that installs the package denies it', async () => { + const files = { 'miniapp/client/app.js': 'const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n' }; + const anyVersion = await run(files); + assert.deepEqual(codes(anyVersion), ['ACCESS_UNDECLARED']); + assert.equal(anyVersion[0].level, 'warning'); + assert.match(anyVersion[0].message, /before 3\.1\.5/u); + const older = await run(files, { minimum: '3.1.4' }); + assert.equal(older[0].level, 'warning'); + for (const minimum of ['3.1.5', '3.2.0']) { + const gated = await run(files, { minimum }); + assert.deepEqual(codes(gated, 'error'), ['ACCESS_UNDECLARED'], minimum); + } + assert.deepEqual(await run(files, { access: { ...NONE, microphone: true }, minimum: '3.1.5' }), []); +}); + +test('usage: one diagnostic per rule per file, and only payload scripts are scanned', async () => { + const repeated = await run({ 'miniapp/client/app.js': 'context.tool.call(a);\ncontext.tool.call(b);\n' }); + assert.equal(repeated.length, 1); + assert.equal(repeated[0].path, 'miniapp/client/app.js'); + assert.deepEqual( + await run({ + 'miniapp/client/client-api.ts': '// context.tool.call(name)\n', + 'miniapp/node/node_modules/lib/index.js': 'context.tool.call(name);\n', + 'tests/agent.test.mjs': 'context.agent.run({ text });\n', + 'miniapp/client/notes.md': 'speech.transcribe(audio)\n', + }), + [], + ); +}); From 5d3837cbe7461b403b39beac5d7b8994379343c2 Mon Sep 17 00:00:00 2001 From: dazhi Date: Mon, 26 Oct 2026 18:04:15 +0800 Subject: [PATCH 2/3] docs: publish the MiniMax Code 3.1.5 Mini App contract - package-contract.md: engines, toolAccess, deviceAccess, the "session.read" minimum (3.1.5), per-item permission switches, the minimum-version table, and how npm run check compares declared access with the code. - agent.md: session.run and session.read, whenBusy follow-ups, the per-minute turn limit, reading and observing the user's sessions, and the errors when the user switches access off. - tool.md (new): context.tool list/call/request, the permission prompt, and the error table. - client.md: capabilities.has, browser permissions on 3.1.5, speech.transcribe, tool.request. - runtime.md: context.tool and context.capabilities. - security.md: reading conversations, calling tools, and recording the microphone. - docs/snippets/audio-capture.ts: the Host's 16 kHz PCM capture helper. - AGENTS.md, CONTRIBUTING, root READMEs: the new docs, declared access, and the 3.1.5 minimum. - hello-miniapp: miniapp-api.ts and client-api.ts synced to the 3.1.6 Host snapshots; only their doc references are pointed at this repository. Assisted-by: Claude Opus 5.5 (OpenCode) --- AGENTS.md | 44 +-- CONTRIBUTING.md | 4 +- CONTRIBUTING.zh-CN.md | 4 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/agent.md | 121 ++++++-- docs/client.md | 56 +++- docs/package-contract.md | 88 +++++- docs/runtime.md | 12 +- docs/security.md | 32 ++- docs/snippets/audio-capture.ts | 96 +++++++ docs/tool.md | 117 ++++++++ .../miniapp/client/client-api.ts | 199 ++++++++++++- .../hello-miniapp/miniapp/node/miniapp-api.ts | 263 +++++++++++++++++- 14 files changed, 957 insertions(+), 83 deletions(-) create mode 100644 docs/snippets/audio-capture.ts create mode 100644 docs/tool.md diff --git a/AGENTS.md b/AGENTS.md index e4855fbd..5eccde01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,13 +11,15 @@ update one package at `plugins///`. 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. Read - `docs/client.md` before the page calls `window.mcode`, and `docs/agent.md` before declaring - `agentAccess`. + `docs/client.md` before the page calls `window.mcode` or a browser permission such as + `getUserMedia`, `docs/agent.md` before declaring `agentAccess`, and `docs/tool.md` before + declaring `toolAccess`. 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, 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`. + network hosts, spawned processes, Agent turns, conversations read, tools called, microphone use, + 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. @@ -38,10 +40,15 @@ update one package at `plugins///`. - 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. + App payload, the page APIs, the Agent API, and the tool API; treat `context.hostConnector` and the + other capabilities as outside its scope. +- Declare exactly the access the code uses: `agentAccess` (`session.run`, `session.read`), + `toolAccess`, `deviceAccess`. A call without its declaration fails at runtime, and + `npm run check` reports it as `ACCESS_UNDECLARED`; do not declare access the app does not use. +- `agentAccess` gives an Agent with files, shell, and MCP, and `session.read` exposes every user + conversation. Never answer a `permission.requested` with `allowOnce` unless the user chose it. +- The user can switch any declared access off at any time. Handle `NOT_PERMITTED`, + `TOOL_NOT_PERMITTED`, and `NotAllowedError` where the call is made, and keep the app running. - 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. @@ -51,17 +58,22 @@ 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, Agent turns, tested environment); the package cannot damage -Host or user data. Everything else is a suggestion. +behaviour (files, network, processes, Agent turns, conversations read, tools called, microphone +use, 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, minimum client - versions +- `docs/package-contract.md` — layout, the three manifests, portable paths, limits, permission + switches, minimum client versions, declared access versus code - `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/client.md` — page APIs: `capabilities`, browser permissions, `message.append`, `agent`, + `speech.transcribe`, `tool.request`, `window` +- `docs/agent.md` — `agentAccess`, sessions, calls, `whenBusy`, reading and observing sessions, + events, errors +- `docs/tool.md` — `toolAccess`, `context.tool`, permission prompts, errors - `docs/security.md` — process boundary, state, writing outside `dataDir`, spawning, network, Agent - turns + turns, reading conversations, calling tools, microphone +- `docs/snippets/audio-capture.ts` — microphone capture in the format `speech.transcribe` takes - `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 470185f5..27d282cc 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`, `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`). +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`, `docs/tool.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, any Agent turns it runs, conversations it reads, tools it calls, and microphone use. Screenshots or GIFs are welcome. Declare in `miniapp/miniapp.json` exactly the access the code uses; `npm run check` reports calls without a declaration. If the app uses a feature that needs a newer client, such as `agentAccess`, `toolAccess`, `deviceAccess`, 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 e109fba7..8487a003 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/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”)。 +2. 从 `examples/hello-miniapp/` 复制开始。保留 `.minimax-plugin/` 隐藏目录、`package.json`、`miniapp/` 和运行所需资源。插件应能独立复制使用,不依赖仓库里的其他目录。具体规则见 `docs/package-contract.md`、`docs/runtime.md`、`docs/client.md`、`docs/agent.md`、`docs/tool.md` 和 `docs/security.md`(英文)。 +3. 添加简短的英文 `README.md`,可另附 `README.zh-CN.md` 并互相链接。说明用途、安装与使用方式、已验证的客户端版本和系统,以及需要的配置、文件访问、网络请求、会运行的 Agent 任务、会读取的会话、会调用的工具和麦克风使用情况。欢迎附上截图或 GIF。`miniapp/miniapp.json` 中只声明代码实际用到的权限;代码调用了未声明的能力时,`npm run check` 会报出来。如果用到了需要较新客户端的能力(如 `agentAccess`、`toolAccess`、`deviceAccess`、`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 a27a4fb0..5f410e36 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,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. 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. +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 need a newer client, and older versions cannot install them: running Agent tasks or opening in their own window needs MiniMax Code 3.1.2 or later; reading your conversations, calling your installed tools, or using the microphone needs 3.1.5 or later. From 3.1.5 you can switch each of these permissions off per app in the plugin details. ### 1. Download diff --git a/README.zh-CN.md b/README.zh-CN.md index 32478d21..62f8b12d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -112,7 +112,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 ### 使用要求 -需要支持 MiniApp 的 MiniMax Code 桌面端版本。各作品的 README 会说明已验证的客户端版本、操作系统、所需配置和已知限制,兼容性以具体作品为准。使用较新能力(如运行 Agent 任务、在独立窗口中打开)的作品需要 MiniMax Code 3.1.2 或更高版本,旧版本无法安装。 +需要支持 MiniApp 的 MiniMax Code 桌面端版本。各作品的 README 会说明已验证的客户端版本、操作系统、所需配置和已知限制,兼容性以具体作品为准。使用较新能力的作品需要较新的客户端,旧版本无法安装:运行 Agent 任务、在独立窗口中打开需要 MiniMax Code 3.1.2 或更高版本;读取你的会话、调用你已安装的工具或使用麦克风需要 3.1.5 或更高版本。从 3.1.5 起,你可以在插件详情里按作品逐项关闭这些权限。 ### 1. 下载 diff --git a/docs/agent.md b/docs/agent.md index 061699d3..2b6b3e74 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,6 +1,7 @@ # Mini App Agent API -Verified against MiniMax Code 3.1.2. Earlier clients reject packages that declare `agentAccess`. +Verified against MiniMax Code 3.1.6. Clients before 3.1.2 reject packages that declare +`agentAccess`, and clients before 3.1.5 reject `"session.read"`. 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, @@ -12,25 +13,31 @@ at runtime; a page that only prepares text for the user to send should use ```json { - "agentAccess": ["session.run"], + "agentAccess": ["session.run", "session.read"], "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`. -- `"session.read"` (MiniMax Code 3.1.4 or later, observed on 3.1.4-preview) adds read access to - sessions: on the page, `agent.session()` with no argument returns the chat the Mini App is open in - (`sessionId`, `title`, `agent`, `status`, `workspaceDir`, `isDefaultWorkspace`, `createdAtMs`, - `updatedAtMs`, `model`, `lastAssistantText`) and rejects with `not_permitted` when it is not open - in a chat; `agent.session(sessionId)` reads one session and `agent.sessions()` lists the user's - sessions with their titles and folders. Read only what you need and say so in the README. -- `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`). +- `"session.run"` runs Agent turns in this Mini App's own sessions: `run`, `answer`, `reply`, + `cancel`, `open`, `models`, `agents`, `create`. +- `"session.read"` (MiniMax Code 3.1.5 or later) lists, reads, and observes the user's own + conversations, including the final reply of every turn: `sessions()`, `session()`, and + `observe()` (see [Reading and observing the user's sessions](#reading-and-observing-the-users-sessions)). + It does not let the Mini App run, answer, or continue those conversations. +- The two values are independent; declare both when the Mini App needs both. Either one lets the + Mini App read and observe its own sessions. +- `agentConcurrency` (1–32, default 4) caps how many turns of this Mini App run at once. +- Changing any of these fields restarts the Mini App process when the package is updated. +- The user can switch either value off in the plugin details (MiniMax Code 3.1.5 or later). The + Mini App keeps running and `context.agent` stays; calls covered by that value fail with + `NOT_PERMITTED` / `not_permitted` until the user switches it back on. Show an explanation in the + page instead of failing. +- State in the README `## Data & access` that the Mini App runs Agent turns or reads the user's + conversations, and what it does with them (see `docs/security.md`). +- Feature-detect calls added after 3.1.2 with `capabilities?.has(...)`: `agent.observe`, + `agent.sessions`, and `agent.session` exist from 3.1.5. ## Sessions @@ -50,6 +57,8 @@ session list. 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. +- Inside a session this Mini App started, the Agent cannot publish, restart, or stop Mini Apps, and + `@`-mentions do not start other Mini Apps. Sessions it delegates to sub-agents are not covered. ## Calls @@ -58,14 +67,15 @@ Node calls take an options object `{ signal?, onEvent? }`; page calls take `{ on | 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. | +| `run({ text, sessionId?, model?, agent?, whenBusy? })` (page also `requestId?`) | `{ sessionId, turnId?, model?, status, text?, error?, pendingQuestion? }` when the turn ends. `status` is `finished`, `error`, `aborted`, or (only with `whenBusy`) `steered`; `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. | +| `cancel(requestId)` (page only) | Cancels that call or observer; unknown or finished ids do nothing. | +| `sessions()`, `session()`, `observe()` | See [Reading and observing the user's sessions](#reading-and-observing-the-users-sessions). | - 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 @@ -74,6 +84,38 @@ Node calls take an options object `{ signal?, onEvent? }`; page calls take `{ on - 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. +### Follow-ups while the session is busy (`whenBusy`) + +From MiniMax Code 3.1.5, `run({ sessionId, text, whenBusy })` decides what happens when that session +is already running a turn; without `whenBusy` the call fails with `busy`. On an idle session both +values just start a turn. + +- `'queue'`: the text waits in the session queue and runs as its own turn after the turns ahead of + it. You get `run.queued` with its queue `position`, then the call resolves like any `run`. + Cancelling it removes the text from the queue. +- `'steer'`: the text is handed to the running turn, which reads it at its next step and adjusts its + work. You get `message.pending`, then `message.steered`, and the call resolves with + `status: 'steered'` and no `text`: the reply arrives with the running turn's own call. If that + turn answers before reading the text, the text runs as the next turn and the call follows it like + `'queue'`. A steered text cannot be withdrawn. + +For a chat-style Mini App, send each incoming message with `whenBusy: 'queue'` and post each result's +`text`; use `'steer'` for messages meant to change the work in progress, and post nothing for a +`steered` result. If the user stopped the session while messages were queued, a `whenBusy` call +waits behind them until the user resumes the queue; it keeps its run slot meanwhile, so cancel it +to free the slot. + +### Limits + +- At most `agentConcurrency` turns per Mini App run at once, one per session. Further `run` / + `answer` calls wait in order, up to four times `agentConcurrency`; beyond that they fail with + `busy`. +- A Mini App starts at most 12 turns per 60 seconds, or 5 × `agentConcurrency` when that is larger + (MiniMax Code 3.1.5 or later). Past that, calls fail with `busy`. The count survives a Mini App + restart. +- Node sees why a call is `BUSY` in `reason`: `session_busy`, `queue_full`, `rate_limited`, or + `observer_limit`. The page only receives `busy`. + ## Interaction Two interactions block a turn. The Mini App renders them itself, or lets the Host do it with the @@ -125,14 +167,45 @@ 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. | +| `run.queued` | The call waits; `position` is its 1-based place in line. Sent once per wait, before the turn: for a run slot, or with `whenBusy: 'queue'` for the session queue. | +| `message.pending` / `message.steered` | Only with `whenBusy: 'steer'`: the text was handed to the running turn, then read by it. `message.steered` is the call's last event. | | `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. | +| `turn.finished` | Last unless the call ended `steered`; same `status`, `text?`, `error?` as the result. | + +## Reading and observing the user's sessions + +These calls exist from MiniMax Code 3.1.5. They need `"session.read"`, except for this Mini App's own +sessions, which either `agentAccess` value covers. They change nothing: a lost answer is +`AGENT_UNAVAILABLE` / `runtime_unavailable` and safe to retry. + +- `sessions()` resolves with `{ sessions }`, the 50 most recently updated user conversations, each + `{ sessionId, title?, agent, status, workspaceDir, isDefaultWorkspace, createdAtMs, updatedAtMs }`. + `status` is `idle`, `running`, `error`, or `aborted`; `isDefaultWorkspace` is `true` when the + conversation has no project. Mini App, scheduled, and sub-agent sessions are not listed. +- `session(sessionId)` adds `model?`, `error?: { message }` while `status` is `error`, and + `lastAssistantText?`, the final text of the latest reply. +- On the page, `session()` without an id reads the chat the page is open in, for example to work in + its project directory; it resolves with `undefined` outside a chat. That chat is the user's + conversation, so it needs `"session.read"` too. Node has no current chat: the page passes the id. +- `observe({ all: true }, { onEvent })` reports `turn.started`, `message.committed` (the final + reply), and `turn.finished` of every user conversation, each with `sessionId` and the + conversation's `title` when it has one. `observe({ sessionId }, { onEvent })` reports every + progress event of one session, turn after turn. Pass exactly one target. +- An observer never receives `permission.requested` or `question.requested`, and cannot answer + them. `turn.started` carries `model` only when the Host could read it, and `turn.finished` comes + without `text` when the Host could not read the final reply; read `session()` if you need it. + When events back up, `text.delta` and `tool.*` events may be dropped, never the start, the final + reply, or the finish. +- `observe` resolves when the subscription ends: Node aborts its `signal`, the page calls + `cancel(requestId)` with the `requestId` it passed, the session is deleted, the Mini App stops, or + the user switches `"session.read"` off. It rejects when it could not start. At most 4 observers per + Mini App (`busy`); they use no run slots. Missed events are not replayed, so refresh with + `session()` after re-subscribing. ## Errors @@ -140,13 +213,13 @@ Node errors carry `code` and `retryable`; page errors are an `Error` whose `mess | 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. | +| `NOT_PERMITTED` | `not_permitted` | Not declared in `agentAccess`, switched off by the user, or (page `open`) the Mini App is not open in a chat. | +| `BUSY` | `busy` | The session already runs a turn, the queue is full, the per-minute limit was hit, or 4 observers are running. Retryable. | +| `SESSION_NOT_FOUND` | `session_not_found` | Not a session this Mini App may use. | +| `INVALID_ARGUMENTS` | `invalid_request` | Malformed input, an unusable model, `agent` on an existing session, or not exactly one `observe` target. | | `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_CANCELLED` | `cancelled` | Cancelled before anything ran, or removed from the session queue. | +| `AGENT_OUTCOME_UNKNOWN` | `result_unknown` | The turn may have run, or a steered text could not be withdrawn. 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 diff --git a/docs/client.md b/docs/client.md index dbfc61e6..08d62a83 100644 --- a/docs/client.md +++ b/docs/client.md @@ -1,6 +1,6 @@ # Mini App page APIs -Verified against MiniMax Code 3.1.2. +Verified against MiniMax Code 3.1.6. 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 @@ -11,6 +11,25 @@ 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.capabilities.has(name)` + +From MiniMax Code 3.1.5, `window.mcode.miniapp.capabilities.has(name)` answers synchronously whether +this client supports a feature, named by its API path: `message.append`, `agent.run`, `agent.open`, +`agent.models`, `agent.agents`, `agent.create`, `agent.observe`, `agent.sessions`, +`agent.session`, `speech.transcribe`, `tool`, `tool.request`, `window`, `hostConnector`. The list is +the same on the page and in Node (`context.capabilities`), and it describes the client, not this +Mini App's grants: `has('agent.run')` is true even without `agentAccess`. Earlier clients have no +`capabilities`, so write `window.mcode?.miniapp?.capabilities?.has('agent.observe')`. + +## Browser permissions + +From MiniMax Code 3.1.5 the Host decides every browser permission request from a Mini App page. +Sanitized clipboard writes, fullscreen, pointer lock, and keyboard lock work without a declaration. +The microphone works only when `miniapp.json` declares `deviceAccess: ["microphone"]` and the user +has not switched it off; otherwise `getUserMedia` rejects with `NotAllowedError`. Camera, +geolocation, notifications, and everything else a browser would prompt for are denied and cannot be +declared yet. Earlier clients granted these requests silently. + ## `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 @@ -44,10 +63,41 @@ async function addToChat(text, status) { ## `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 +Runs Agent turns from the page, and from MiniMax Code 3.1.5 also reads and observes the user's +conversations. Present only on MiniMax Code 3.1.2 or later; calls reject with `not_permitted` +unless `miniapp.json` declares the matching `agentAccess` value. The calls, sessions, events, and errors are in `docs/agent.md`. +## `miniapp.speech.transcribe(input, options?)` + +From MiniMax Code 3.1.5, `window.mcode.miniapp.speech.transcribe({ audio, format }, { onPartial? })` +turns one utterance the page recorded into text with the Host's speech recognition and resolves +`{ text }`. The page owns the microphone and the interaction; the Host only transcribes. It needs +`deviceAccess: ["microphone"]` and the user's microphone switch on, or it rejects with +`not_permitted`. + +- `format` must be exactly `{ sampleRate: 16000, channelCount: 1, sampleFormat: 's16' }`, and + `audio` an `Int16Array` (or the buffer behind one) of 16 kHz mono samples, at most 16,000,000 + bytes (500 seconds). WAV, WebM, Ogg, FLAC, MP3, and `MediaRecorder` output are rejected with + `invalid_request`. `docs/snippets/audio-capture.ts` records in exactly this format: copy it next + to your Client source. +- The Host returns text after it has the whole utterance; it does not caption live speech. + `onPartial` reports the transcript so far while it works. Decide in the page when an utterance + ends (push to talk, a toggle, or splitting on silence) and call once per utterance. +- One call per Mini App runs at a time; a second call rejects with `busy`. +- Other failures: `runtime_unavailable` (speech recognition is off or the user is signed out; + offer typing instead), `transcription_failed` and `result_unknown` (the same audio may be sent + again), `audio_too_large`. +- The Host shows nothing while a page records. Show your own recording indicator, stop the + microphone as soon as the utterance ends, and make the stop path work even when the pointer is + released outside the button or `getUserMedia` has not resolved yet. + +## `miniapp.tool.request(names)` + +From MiniMax Code 3.1.5, a page whose manifest declares `toolAccess: ["call"]` can ask the user, +inside a click handler, to always allow tools its Node entry calls. Listing and calling stay in +Node. See `docs/tool.md`. + ## `miniapp.window` (proposed) Present on MiniMax Code 3.1.2 or later. **Proposed:** the shape may still change. Feature-detect diff --git a/docs/package-contract.md b/docs/package-contract.md index 5f43cf6e..fec221ca 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -1,14 +1,15 @@ # Mini App package contract -Verified against MiniMax Code 3.1.2. +Verified against MiniMax Code 3.1.6. 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`, 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)). +`docs/runtime.md`, a stated minimum client version for newer features (see +[Minimum client version](#minimum-client-version)), and code that only calls what the manifest +declares (see [Declared access must match the code](#declared-access-must-match-the-code)). ## Layout @@ -96,7 +97,9 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, } ``` -- `schemaVersion` is `1`. Unknown top-level fields are rejected. +- `schemaVersion` is `1`. Unknown top-level fields are rejected: MiniMax Code 3.1.5 and later report + `MINIAPP_MANIFEST_FIELD_UNSUPPORTED` naming the field, which on an installed client means the + client is older than the package; earlier clients report `MINIAPP_MANIFEST_INVALID`. - `artifacts.client` and `artifacts.node` are non-empty arrays of unique paths under `miniapp/`. Each path must exist. These are the runtime payload roots: exactly what MiniMax Code hashes and installs. Any `node_modules` directory is excluded from payloads. @@ -116,12 +119,41 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, `^[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: an array of `"session.run"` (run Agent turns) and `"session.read"` - (read sessions: the page's `agent.session()` returns the chat it is open in, see `docs/agent.md`). - Absent or `[]` means no Agent access. See `docs/agent.md` before declaring it. Requires MiniMax - Code 3.1.2 or later; `"session.read"` requires 3.1.4 or later. + (list, read, and observe the user's own conversations). Neither implies the other; declare both + when the Mini App needs both. Absent or `[]` means no Agent access. See `docs/agent.md` before + declaring it. Requires MiniMax Code 3.1.2 or later; `"session.read"` requires 3.1.5 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. +- `toolAccess` is optional. The only allowed value is `["call"]`, which gives the Node entry + `context.tool` to call the MCP and Connector tools installed on the user's MiniMax Code, each call + judged by the user's permission rules. See `docs/tool.md`. Requires MiniMax Code 3.1.5 or later. +- `deviceAccess` is optional. The only allowed value is `["microphone"]`, which lets the page call + `getUserMedia({ audio })` and `window.mcode.miniapp.speech.transcribe` (see `docs/client.md`). + Requires MiniMax Code 3.1.5 or later. +- `engines` is optional: `{ "mcode": ">=MAJOR.MINOR.PATCH" }`, nothing else. A client at 3.1.5 or + later but older than the bound keeps the plugin with its Skills and MCP servers, and withholds + the Mini App. Clients before 3.1.5 do not know the field and reject the whole package, so the + bound only matters above 3.1.5. Write it only when the package needs a specific newer client; it + is not required for the features in the table below. Requires MiniMax Code 3.1.5 or later. + +### Permission switches + +From MiniMax Code 3.1.5 the user can switch off, item by item in the plugin details, what the +manifest declares: each `hostConnectorAccess` provider, each `agentAccess` value, `toolAccess` +`"call"`, and `deviceAccess` `"microphone"`. Everything starts on, with no first-use prompt. A switch +applies at once and does not restart the Mini App: `context.agent` and `context.tool` stay present, +and their calls fail until the user switches the item back on: + +| Switched off | What fails | +| --- | --- | +| `session.run` | Running turns end as `aborted`; `run`, `reply`, `answer`, `cancel`, `open`, `models`, `agents`, `create` fail with `NOT_PERMITTED` (`not_permitted` on the page). | +| `session.read` | Observers end; `sessions()`, `observe({ all: true })`, and reading or observing the user's sessions fail the same way. | +| `tool:call` | `context.tool.call` fails with `TOOL_NOT_PERMITTED`; `list()` reports every tool as `deny`. | +| `microphone` | The next `getUserMedia` fails with `NotAllowedError` and `speech.transcribe` with `not_permitted`; a recording already open keeps its stream until the page stops it. | + +Handle these errors where the call is made and explain in the page what the user can turn back on. +`surface.window` flags are not switches. ### `surface.window` @@ -173,13 +205,47 @@ the package cannot be installed there at all. | --- | --- | --- | | `agents` | `plugin.json` | 3.1.2 | | `agentAccess` | `miniapp/miniapp.json` | 3.1.2 | -| `agentAccess` with `"session.read"` | `miniapp/miniapp.json` | 3.1.4 | +| `agentAccess` with `"session.read"` | `miniapp/miniapp.json` | 3.1.5 | | `agentConcurrency` | `miniapp/miniapp.json` | 3.1.2 | +| `deviceAccess` | `miniapp/miniapp.json` | 3.1.5 | +| `engines` | `miniapp/miniapp.json` | 3.1.5, or its own bound when higher | | `surface.window` | `miniapp/miniapp.json` | 3.1.2 | +| `toolAccess` | `miniapp/miniapp.json` | 3.1.5 | + +A 3.1.4 preview build accepted `"session.read"`, but the released 3.1.4 rejects it. + +The package's minimum is the highest entry it declares. When it has one, name a MiniMax Code +version at or above it under `## Tested environment` in its README, for example +`- MiniMax Code 3.1.5 on macOS 15`. `npm run check` warns until it finds one there. -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. +To use a newer page or Node API only where it exists, without raising the minimum, feature-detect +it: `capabilities?.has('agent.observe')` (MiniMax Code 3.1.5 and later), or test that the method +exists. A manifest field cannot be feature-detected: declaring it is what raises the minimum. + +## Declared access must match the code + +`npm run check` scans the files that run in the page or in Node (`.js`, `.mjs`, `.cjs`, `.html` +under `artifacts.client` and `artifacts.node`, outside `node_modules`) for calls that need a grant +the manifest does not make, and reports them as `ACCESS_UNDECLARED`. + +| Call | Needs | Level | +| --- | --- | --- | +| `context.agent.*` or page `agent.*` | `agentAccess` | error | +| `agent.sessions()`, `agent.observe({ all: true })`, page `agent.session()` without an id | `agentAccess` `"session.read"` | error | +| `context.tool.*`, page `tool.request()` | `toolAccess: ["call"]` | error | +| `speech.transcribe()` | `deviceAccess: ["microphone"]` | error | +| `getUserMedia()` | `deviceAccess: ["microphone"]` | error when the package's minimum is 3.1.5 or later; warning otherwise | + +The first four never work without the grant, on any client. `getUserMedia` worked without a grant +before MiniMax Code 3.1.5, and 3.1.5 and later deny it with `NotAllowedError`. A package whose +minimum is below 3.1.5 still installs on those earlier clients, so the check only warns; declaring +`deviceAccess` fixes the call and raises the minimum to 3.1.5. Camera, geolocation, notifications, +and every other permission a browser would prompt for are denied to Mini App pages on 3.1.5 and +later and cannot be declared yet. + +The scan matches source text, so it misses calls through a renamed variable and can flag dormant +code, such as an engine's audio input that the app never turns on. Explain a warning in the pull +request instead of declaring a permission the app does not use. ## Portable paths diff --git a/docs/runtime.md b/docs/runtime.md index 63fc2fc5..e5096310 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -1,6 +1,6 @@ # Mini App Node runtime -Verified against MiniMax Code 3.1.2. +Verified against MiniMax Code 3.1.6. ## Loading @@ -29,7 +29,13 @@ 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`. | +| `agent` | Present when `miniapp.json` declares `agentAccess` (`"session.run"`, and from MiniMax Code 3.1.5 also `"session.read"`). See `docs/agent.md`. | +| `tool` | Present when `miniapp.json` declares `"toolAccess": ["call"]` (MiniMax Code 3.1.5 or later). See `docs/tool.md`. | +| `capabilities` | `has(name)` answers whether this client supports a feature, such as `has('agent.observe')`; the names are listed in `docs/client.md`. Absent before MiniMax Code 3.1.5, so write `context.capabilities?.has(...)`. | + +`agent` and `tool` follow the manifest, not the user's permission switches: when the user switches +a granted item off, the object stays and its calls fail until the item is switched back on (see +"Permission switches" in `docs/package-contract.md`). ## `start(context)` @@ -38,7 +44,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. +- Do not call `context.agent` or `context.tool` inside `start`; call them from a request handler. ## `dispose()` diff --git a/docs/security.md b/docs/security.md index 1b517ce7..c13402da 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Mini App security rules -Verified against MiniMax Code 3.1.2. +Verified against MiniMax Code 3.1.6. ## Process boundary @@ -47,3 +47,33 @@ shell commands, and call MCP tools. 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. + +## Reading the user's conversations + +`"session.read"` lets the Mini App read the title, project directory, and final reply of every turn +in every user conversation. + +- Declare it only when following the user's conversations is the Mini App's purpose, and say so in + the plugin `description` and the README `## Data & access`. +- Keep what you read on the user's machine. Do not log it, send it to a network host, or store more + of it than the Mini App shows, unless the user asked for that. + +## Calling tools + +`toolAccess` lets the Node entry call the MCP and Connector tools the user has installed (see +`docs/tool.md`). + +- List in the README `## Data & access` which tools the Mini App calls and what for. +- Every call goes through the user's permission rules. Do not try to work around a `deny` or an + unanswered prompt, and ask for "always allow" with `request()` only from a user action, for the + tools the Mini App needs. +- Treat tool results as untrusted input, like any network response. + +## Microphone + +`deviceAccess: ["microphone"]` lets the page record the user. + +- Say in the README `## Data & access` when the Mini App records, and where the audio goes: the + Host's speech recognition, a network host, or a file. +- Record only after a user action, show that recording is on, and stop the microphone as soon as the + utterance ends. diff --git a/docs/snippets/audio-capture.ts b/docs/snippets/audio-capture.ts new file mode 100644 index 00000000..f42f7c0c --- /dev/null +++ b/docs/snippets/audio-capture.ts @@ -0,0 +1,96 @@ +/** + * Copyable microphone capture for `window.mcode.miniapp.speech.transcribe`. + * Produces exactly the PCM the Host transcribes: 16 kHz, mono, signed 16-bit little-endian. + * No Host or runtime import; copy it into the Client bundle. Keep the .ts filename because + * Electron dependency packaging filters out .d.ts assets. See docs/client.md for usage. + * Copy it next to client-api.ts from examples/hello-miniapp/miniapp/client/, whose types it imports. + */ +import type { MiniAppSpeechAudioFormat } from './client-api.js'; + +export const SPEECH_AUDIO_FORMAT: MiniAppSpeechAudioFormat = { + sampleRate: 16000, + channelCount: 1, + sampleFormat: 's16', +}; + +export interface SpeechCapture { + /** Stops the microphone and returns everything recorded since `startSpeechCapture`. */ + stop(): Int16Array; + /** Stops the microphone and discards the recording. */ + cancel(): void; + /** 0..1 RMS level of the latest audio block, for a level meter. */ + readonly level: number; +} + +const BUFFER_SIZE = 4096; + +function toInt16(samples: Float32Array): Int16Array { + const out = new Int16Array(samples.length); + for (let i = 0; i < samples.length; i += 1) { + const s = Math.max(-1, Math.min(1, samples[i] ?? 0)); + out[i] = s < 0 ? s * 0x8000 : s * 0x7fff; + } + return out; +} + +/** + * Opens the microphone (the Host grants it only when this Mini App declares + * `deviceAccess: ["microphone"]` and the user left the switch on; otherwise `getUserMedia` + * rejects with `NotAllowedError`) and accumulates 16 kHz mono PCM until `stop()`. + */ +export async function startSpeechCapture( + options: { deviceId?: string } = {}, +): Promise { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + ...(options.deviceId ? { deviceId: { exact: options.deviceId } } : {}), + }, + }); + // The AudioContext resamples the microphone to the Host's rate; do not change it. + const context = new AudioContext({ sampleRate: SPEECH_AUDIO_FORMAT.sampleRate }); + const source = context.createMediaStreamSource(stream); + const processor = context.createScriptProcessor(BUFFER_SIZE, 1, 1); + const chunks: Int16Array[] = []; + let level = 0; + processor.onaudioprocess = (event) => { + const input = event.inputBuffer.getChannelData(0); + chunks.push(toInt16(input)); + let sum = 0; + for (let i = 0; i < input.length; i += 1) sum += (input[i] ?? 0) ** 2; + level = Math.min(1, Math.sqrt(sum / input.length) / 0.15); + }; + source.connect(processor); + processor.connect(context.destination); + + const teardown = () => { + processor.disconnect(); + source.disconnect(); + for (const track of stream.getTracks()) track.stop(); + void context.close().catch(() => undefined); + }; + + return { + get level() { + return level; + }, + stop() { + teardown(); + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const pcm = new Int16Array(total); + let offset = 0; + for (const chunk of chunks) { + pcm.set(chunk, offset); + offset += chunk.length; + } + return pcm; + }, + cancel() { + teardown(); + chunks.length = 0; + }, + }; +} diff --git a/docs/tool.md b/docs/tool.md new file mode 100644 index 00000000..7cfecb4d --- /dev/null +++ b/docs/tool.md @@ -0,0 +1,117 @@ +# Mini App tool API + +Verified against MiniMax Code 3.1.6. Clients before 3.1.5 reject packages that declare +`toolAccess`. + +`context.tool` lets the Node entry call the tools installed on the user's MiniMax Code: every +plugin's MCP servers (this one's included), the user's own MCP servers, the Host's built-in MCP +servers, and every Connector provider the user has connected. Agent built-in tools such as files, +shell, and browser are not reachable. Declare it only when the Mini App really calls tools. + +```json +{ "toolAccess": ["call"] } +``` + +- `context.tool` is present whenever the manifest declares `"toolAccess": ["call"]`, the only + accepted value. `hostConnectorAccess` does not grant it. +- The grant only lets the Mini App ask. Every call is judged by the user's permission rules, and the + user may be prompted. +- The user can switch `tool:call` off in the plugin details. `context.tool` stays and nothing + restarts: new calls fail with `TOOL_NOT_PERMITTED`, `list()` reports every tool as `deny`, and + `request()` answers `deny` without a prompt. A call already running finishes. +- Feature-detect with `context.capabilities?.has('tool')`, and `has('tool.request')` before using + `request()`. +- List in the README `## Data & access` which tools the Mini App calls and why. + +## Listing and calling + +```js +const { tools, partial } = await context.tool.list(); +const search = tools.find((tool) => tool.kind === 'mcp' && tool.server === 'notes' && tool.tool === 'search'); +if (search && search.permission !== 'deny') { + const result = await context.tool.call(search.name, { query: 'budget' }, { signal }); +} +``` + +- A tool is called by the runtime name an Agent sees: `mcp____` or + `connector____`. The Host may shorten or disambiguate MCP names, so take `name` + from `list()`, matching on `kind`, `server` or `provider`, and `tool`; do not build it. +- `list()` returns `{ tools, partial }`; each tool is + `{ name, kind, server | provider, tool, description?, inputSchema, permission }`. `permission` + (`allow`, `ask`, or `deny`) is what a call would meet now; every call is judged again. `partial` is + true when a source could not be listed (a Connector login, an MCP server still starting); the + listed tools still work. +- Call `call()` from request handling, never inside `start(context)`. +- A Connector result is `{ invocationId, kind: 'connector', value }` with the provider's raw value. + An MCP result is `{ invocationId, kind: 'mcp', isError, content, structuredContent? }`; + `isError: true` is the tool reporting its own failure, so show `content` rather than treating it + as a transport error. +- Connector calls time out after 30 seconds and MCP calls after at most 120 seconds, counted after + any prompt. A result over 8 MiB fails with `TOOL_RESULT_TOO_LARGE` even though the tool ran; have + such a tool return a file path or URL instead. +- A server this Mini App serves through `mcpEndpoints` runs in the same Node process: call your own + function instead of `context.tool`. + +## Permission + +Each call meets the user's global permission rules and global permission mode, as an Agent would. +Agent and session rules do not apply, because the call belongs to no Agent or session. + +- `allow` runs the tool. With no rule, MCP and Connector tools are allowed, as for an Agent; + arguments that name paths outside the Host's data directory ask. +- `deny` fails with `TOOL_NOT_PERMITTED`. +- `ask` shows a Host prompt naming this Mini App, the tool, and the arguments, with **Allow once**, + **Always allow**, and **Deny**. Always allow writes the same global rule as the chat approval + card, so it also applies to Agents. The prompt appears when the Host window is visible or this + Mini App has an open page; otherwise the call fails with `TOOL_APPROVAL_REQUIRED`. It waits at + most five minutes (`TOOL_TIMEOUT`). +- The Host owns the prompt. Mini App code cannot draw, answer, or skip it. Plugin hooks do not run + for these calls. + +### Asking ahead with `request()` + +When the Mini App calls tools while nobody is watching, ask for "always allow" while the user is +present: + +```js +// Page, inside a click handler such as a "Connect tools" button. +const states = await window.mcode?.miniapp?.tool?.request([searchName, sendName]); +// Node, while a page of this Mini App is open. +const state = await context.tool.request([searchName]); +``` + +- One Host prompt lists the named tools that are still `ask`, each with a checkbox. The result maps + every name to its state afterwards; it is for your UI only. +- The page may call `request()` only inside a user gesture, with 1 to 10 names; otherwise it + rejects with `invalid_request`. Names come from Node `list()`, passed to the page through your + own route. The page has no `list()` or `call()`. +- In Node, when nobody can be asked, `request()` rejects with `TOOL_APPROVAL_REQUIRED` and the error + carries `permissions`, the unchanged state. + +## Errors + +Errors carry `code`, `message`, `disposition`, and `retryable`. Only `disposition` says whether the +tool may have run: `not_dispatched` means it did not, so retrying is safe when `retryable` is +true; `unknown_after_dispatch` means it may have, so read the domain state before repeating a +write. The Host never retries. + +| `code` | Cause | `disposition` | +| --- | --- | --- | +| `INVALID_ARGUMENTS` | The name is not listed (list again), or the arguments or name list are malformed | `not_dispatched` | +| `TOOL_NOT_PERMITTED` | `tool:call` switched off, a deny rule, or the user chose Deny | `not_dispatched` | +| `TOOL_APPROVAL_REQUIRED` | The rules ask and no Host window can show the prompt | `not_dispatched` | +| `TOOL_REF_STALE` | The tool disappeared between listing and the call; list again | `not_dispatched` | +| `TOOL_UNAVAILABLE` | Tools are not available to this process yet | `not_dispatched` | +| `MCP_SERVER_UNAVAILABLE` | The MCP server failed or is still starting (retryable) | `not_dispatched` | +| `SERVICE_RESTARTED` | The Mini App is restarting (retryable) | `not_dispatched` | +| `TOOL_TIMEOUT` | The prompt or the call ran out of time | either; retryable only when `not_dispatched` | +| `REQUEST_CANCELLED` | Your `signal` aborted the call | either | +| `TOOL_RESULT_TOO_LARGE` | The tool finished but its result exceeds 8 MiB | `unknown_after_dispatch` | +| `TOOL_OUTCOME_UNKNOWN` | The outcome was lost, for example because the channel closed | `unknown_after_dispatch` | + +Connector tools also keep their Connector codes, such as `CONNECTOR_PROVIDER_ERROR` and +`CONNECTOR_UNAVAILABLE`. Page `request()` rejects with an `Error` whose `message` is +`invalid_request`, `not_permitted`, `approval_required`, `runtime_unavailable`, or `result_unknown`. + +The type declarations are `MiniAppToolClient` in `examples/hello-miniapp/miniapp/node/miniapp-api.ts` +and `MiniAppToolRequestAPI` in `examples/hello-miniapp/miniapp/client/client-api.ts`. diff --git a/examples/hello-miniapp/miniapp/client/client-api.ts b/examples/hello-miniapp/miniapp/client/client-api.ts index 2ce7ab69..85b761df 100644 --- a/examples/hello-miniapp/miniapp/client/client-api.ts +++ b/examples/hello-miniapp/miniapp/client/client-api.ts @@ -99,10 +99,14 @@ export interface MiniAppAgentRunResult { 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. */ + /** The model the turn was dispatched with; absent when no turn ran or the text was steered. */ readonly model?: MiniAppAgentModelSelection; - /** `aborted` only when the Host confirmed the abort; an unconfirmed cancel rejects with `result_unknown`. */ - readonly status: 'finished' | 'error' | 'aborted'; + /** + * `aborted` only when the Host confirmed the abort; an unconfirmed cancel rejects with + * `result_unknown`. `steered` (only with `whenBusy`): the text joined the running turn + * `turnId`, whose own call gets the reply, so there is no `text`. + */ + readonly status: 'finished' | 'error' | 'aborted' | 'steered'; /** Final assistant reply; absent when the turn produced none or did not finish. */ readonly text?: string; readonly error?: { readonly code: string; readonly message: string }; @@ -119,21 +123,30 @@ export interface MiniAppAgentRunResult { * 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. + * - `run.queued`: the call waits at 1-based `position`, for a run slot or, with + * `whenBusy: 'queue'`, in the session queue (a `whenBusy` call can get both); sent before the + * turn starts, so it has no `turnId` and names a session only when continuing one. + * - `message.pending` / `message.steered`: with `whenBusy: 'steer'`, the text was handed to the + * running turn, then read by it; `message.steered` is the call's last event. * - `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. + * - `turn.finished`: last unless the call ended `steered`; 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: 'message.pending' | 'message.steered'; + readonly sessionId: string; + readonly turnId: string; + } | { readonly type: 'turn.started'; readonly sessionId: string; @@ -192,6 +205,67 @@ export type MiniAppAgentRunEvent = readonly error?: { readonly code: string; readonly message: string }; }; +/** `observe({ all: true })` only: the conversation's title, when it has one. */ +interface MiniAppAgentObservedTitle { + readonly title?: string; +} + +type MiniAppAgentRunEventOf = Extract< + MiniAppAgentRunEvent, + { readonly type: T } +>; + +/** + * What an observer receives: the run events without `run.queued` and without asks, which reach + * only the run that owns them. `turn.started.model` is present only when the Host could read it. + * Ignore unknown `type` values. + */ +export type MiniAppAgentObservedEvent = + | (Omit, 'model'> & { + readonly model?: MiniAppAgentModelSelection; + } & MiniAppAgentObservedTitle) + | MiniAppAgentRunEventOf<'text.delta' | 'tool.started' | 'tool.finished'> + | (MiniAppAgentRunEventOf<'message.committed'> & MiniAppAgentObservedTitle) + | (MiniAppAgentRunEventOf<'turn.finished'> & MiniAppAgentObservedTitle); + +/** + * `{ all: true }`: `turn.started`, `message.committed` (the final reply) and `turn.finished` of + * every user conversation, with its `title`. `{ sessionId }`: every progress event of that + * session, turn after turn. Exactly one of the two. + */ +export type MiniAppAgentObserveTarget = { readonly sessionId: string } | { readonly all: true }; + +/** One of the user's conversations; `running` while a turn is in flight. */ +export interface MiniAppAgentSessionSummary { + readonly sessionId: string; + readonly title?: string; + /** The Agent the session runs. */ + readonly agent: string; + readonly status: 'idle' | 'running' | 'error' | 'aborted'; + /** The conversation's project directory (absolute) as recorded; it may no longer exist. */ + readonly workspaceDir: string; + /** True when the conversation has no project and runs in the default workspace. */ + readonly isDefaultWorkspace: boolean; + /** Unix ms. */ + readonly createdAtMs: number; + /** Unix ms. */ + readonly updatedAtMs: number; +} + +export interface MiniAppAgentSessionDetail extends MiniAppAgentSessionSummary { + /** The conversation's current model; absent when the Host has none recorded. */ + readonly model?: MiniAppAgentModelSelection; + /** Only while `status` is `error` and the Host recorded a message. */ + readonly error?: { readonly message: string }; + /** Final text of the latest Agent reply, if any. */ + readonly lastAssistantText?: string; +} + +export interface MiniAppAgentSessions { + /** The 50 most recently updated user conversations (no Mini App, scheduled or sub-Agent ones). */ + readonly sessions: readonly MiniAppAgentSessionSummary[]; +} + export interface MiniAppAgentRunInput { /** Plain text, at most 100,000 UTF-16 code units. */ readonly text: string; @@ -208,6 +282,13 @@ export interface MiniAppAgentRunInput { * never changes its agent, so passing it with `sessionId` is `invalid_request`. */ readonly agent?: string; + /** + * Only with `sessionId`, when that session is already running a turn (otherwise it starts one as + * usual). `queue`: the text runs as the next turn and this call follows it. `steer`: the + * running turn reads the text at its next input point; the call resolves `steered`, or follows + * the next turn when the running one answered before reading it. Omit to reject as `busy`. + */ + readonly whenBusy?: 'queue' | 'steer'; /** Your id for `cancel(requestId)`; 1-128 characters, unique among this page's active runs. */ readonly requestId?: string; } @@ -237,8 +318,10 @@ export interface MiniAppAgentRunOptions { } /** - * 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; + * Absent on older Hosts. `run` / `reply` / `answer` / `open` / `models` / `agents` / `create` reject + * with `not_permitted` unless the manifest declares `agentAccess: ["session.run"]`; `observe` / + * `sessions` need `"session.read"` for anything but this Mini App's own sessions (see + * docs/agent.md). 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 @@ -255,7 +338,8 @@ export interface MiniAppAgentRunOptions { * `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. + * `busy` means the session already has a run in progress (never with `whenBusy`) 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 @@ -305,16 +389,113 @@ export interface MiniAppAgentAPI { * although the session may already exist. */ create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; + /** + * Watch the user's Agent at work (see `MiniAppAgentObserveTarget`). Pass a `requestId` to be + * able to stop it with `cancel(requestId)`. Resolves when the subscription ended: cancelled, the + * session was deleted, the page or Mini App stopped, or `session.read` was switched off. Rejects + * with `invalid_request` (not exactly one target), `not_permitted`, `session_not_found`, or + * `busy` past 4 observers per Mini App; observers use no run slots. Check + * `capabilities?.has('agent.observe')` first: older Hosts lack it. + */ + observe( + target: MiniAppAgentObserveTarget & { readonly requestId?: string }, + options: { readonly onEvent: (event: MiniAppAgentObservedEvent) => void }, + ): Promise; + /** Check `capabilities?.has('agent.sessions')` first: older Hosts lack it. */ + sessions(): Promise; + /** + * The chat this page is open in (needs `session.read`: it is the user's conversation, not this + * Mini App's); undefined when the page is not in a chat tab. Check + * `capabilities?.has('agent.session')` first. + */ + session(): Promise; + /** One conversation with its model and latest reply. */ + session(sessionId: string): Promise; +} + +/** + * Page entry for asking "always allow" ahead of time, while the user is present, so later Node + * calls need no prompt. Listing and calling tools is Node `context.tool` only. Needs + * `"toolAccess": ["call"]` and a running Node process; check + * `capabilities.has('tool.request')`. + */ +export interface MiniAppToolRequestAPI { + /** + * Shows one Host prompt for the named tools (runtime names from Node `context.tool.list()`) + * that are still `ask`; the user ticks which to always allow. Resolves each name's state + * afterwards; the Host never trusts this map back. Only inside a user gesture + * (`navigator.userActivation.isActive`), 1–10 distinct names, else `invalid_request`. + * Other rejections: `not_permitted` (no `toolAccess`), `approval_required` (no Host window + * can show the prompt), `runtime_unavailable` (Node process not running), `result_unknown`. + */ + request(names: readonly string[]): Promise>; } export interface MiniAppClientAPI { readonly message: MiniAppMessageAPI; readonly agent?: MiniAppAgentAPI; + /** Absent on Hosts without `tool.request`. */ + readonly tool?: MiniAppToolRequestAPI; /** * Proposed: shape may still change. Feature-detect with * `window.mcode?.miniapp?.window` before use. */ readonly window?: MiniAppWindowHandle; + /** Absent on older Hosts; check `capabilities.has('speech.transcribe')`. */ + readonly speech?: MiniAppSpeechAPI; + /** + * Synchronous probe of Host features, fixed per Host version; independent of this Mini App's + * manifest or grants. Absent on older Hosts: check `window.mcode?.miniapp?.capabilities?.has`. + */ + readonly capabilities?: MiniAppHostCapabilities; +} + +export interface MiniAppHostCapabilities { + has(name: string): boolean; +} + +/** + * The only PCM the Host transcribes. Capture at exactly this (an `AudioContext` created with + * `sampleRate: 16000`, one channel, samples converted to `Int16Array`) and pass the object + * verbatim; any other value is `invalid_request`. See `audio-capture.ts` for a capture helper. + */ +export interface MiniAppSpeechAudioFormat { + readonly sampleRate: 16000; + readonly channelCount: 1; + readonly sampleFormat: 's16'; +} + +export interface MiniAppSpeechTranscribeInput { + /** + * One utterance of samples: an `Int16Array`, or the `ArrayBuffer` / view behind one. At least + * one sample, at most 16,000,000 bytes (500 s). Not an encoded file: WAV, WebM, Ogg, FLAC and + * MP3 are refused as `invalid_request`. + */ + readonly audio: ArrayBuffer | ArrayBufferView; + readonly format: MiniAppSpeechAudioFormat; +} + +export interface MiniAppSpeechTranscribeOptions { + /** The transcript so far while the Host is still transcribing; the final text is the result. */ + readonly onPartial?: (text: string) => void; +} + +/** + * Speech recognition of audio the page captured. Requires the manifest to declare + * `deviceAccess: ["microphone"]` and the user's "Use the microphone" switch to be on (the same + * switch that lets `getUserMedia` succeed); otherwise calls reject with `not_permitted`. + * The Host transcribes after it has the whole utterance: decide the utterance boundary in the + * page (release of a push-to-talk button, a stop button, a silence detector), then call once + * per utterance. One call per Mini App runs at a time; a second is `busy`. + * Failures reject with an `Error` whose `message` is `invalid_request`, `not_permitted`, + * `audio_too_large`, `busy`, `runtime_unavailable` (speech recognition is disabled or the user + * is signed out), `transcription_failed`, or `result_unknown`. + */ +export interface MiniAppSpeechAPI { + transcribe( + input: MiniAppSpeechTranscribeInput, + options?: MiniAppSpeechTranscribeOptions, + ): Promise<{ readonly text: string }>; } /** Live state of the popped-out window: current position, size, and flags — not the manifest declaration. */ diff --git a/examples/hello-miniapp/miniapp/node/miniapp-api.ts b/examples/hello-miniapp/miniapp/node/miniapp-api.ts index f5d8b7a4..f1eed850 100644 --- a/examples/hello-miniapp/miniapp/node/miniapp-api.ts +++ b/examples/hello-miniapp/miniapp/node/miniapp-api.ts @@ -66,6 +66,8 @@ export type HostConnectorErrorCode = | 'INVALID_ARGUMENTS' | 'CONNECTOR_PROVIDER_ERROR' | 'CONNECTOR_UNAVAILABLE' + /** The user switched this provider off for the Mini App; not dispatched. */ + | 'CONNECTOR_NOT_PERMITTED' | 'CONNECTOR_OUTCOME_UNKNOWN'; export interface HostConnectorError extends Error { @@ -82,6 +84,105 @@ export interface HostConnectorError extends Error { }; } +/** What a call would meet now under the user's global permission rules; informational only. */ +export type MiniAppToolPermission = 'allow' | 'ask' | 'deny'; + +/** + * `name` is the runtime name an Agent sees (`mcp____`, `connector____`). + * Take it from `list()`; do not build it. + */ +export type MiniAppTool = + | { + readonly name: string; + readonly kind: 'connector'; + readonly provider: string; + readonly tool: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly permission: MiniAppToolPermission; + } + | { + readonly name: string; + readonly kind: 'mcp'; + /** Any installed MCP server: a Plugin's, the user's `mcp.json`, or a Host built-in one. */ + readonly server: string; + readonly tool: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly permission: MiniAppToolPermission; + }; + +export interface MiniAppToolListResult { + readonly tools: readonly MiniAppTool[]; + /** A source could not be listed; the listed tools are still callable. */ + readonly partial: boolean; +} + +export type MiniAppToolCallResult = + | { + readonly invocationId: string; + readonly kind: 'connector'; + /** Raw provider result, as in `HostConnectorCallResult.value`. */ + readonly value: JsonValue; + } + | { + readonly invocationId: string; + readonly kind: 'mcp'; + /** The MCP tool reported its own failure; `content` keeps its error text. */ + readonly isError: boolean; + /** MCP content blocks such as `{ type: 'text', text }`, passed through as JSON. */ + readonly content: readonly JsonValue[]; + readonly structuredContent?: JsonObject; + }; + +/** + * Thrown with the `HostConnectorError` shape; Connector tools keep their Connector codes too. The + * code names the cause; `disposition` alone says whether the tool may have run (`TOOL_TIMEOUT` and + * `REQUEST_CANCELLED` can be either `not_dispatched` or `unknown_after_dispatch`). + */ +export type MiniAppToolErrorCode = + | HostConnectorErrorCode + | 'INVALID_ARGUMENTS' + | 'TOOL_NOT_PERMITTED' + | 'TOOL_APPROVAL_REQUIRED' + | 'TOOL_REF_STALE' + | 'TOOL_UNAVAILABLE' + | 'TOOL_TIMEOUT' + | 'TOOL_RESULT_TOO_LARGE' + | 'TOOL_OUTCOME_UNKNOWN' + | 'MCP_SERVER_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'SERVICE_RESTARTED'; + +/** + * Present whenever the manifest declares `"toolAccess": ["call"]`, even when the user switched + * `tool:call` off: calls then fail with `TOOL_NOT_PERMITTED`. Every `call` is judged by the user's + * global permission rules: `deny` is `TOOL_NOT_PERMITTED`, `ask` shows a Host prompt, and nobody to + * ask is `TOOL_APPROVAL_REQUIRED`. `list` works before activation; call `call` only from request + * handling after activation. Connector tools time out after 30 s and MCP tools after at most 120 s, + * counted after any prompt. The Host never retries; reconcile an `unknown_after_dispatch` outcome + * before repeating a write. + */ +export interface MiniAppToolClient { + list(options?: HostConnectorCallOptions): Promise; + call( + name: string, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; + /** + * One Host prompt for the named tools (1–10, from `list()`) still at `ask`; the user ticks which + * to always allow. Resolves every name's state afterwards (for your UI only). When nobody can be + * asked it rejects with `TOOL_APPROVAL_REQUIRED` whose error carries `permissions`. With + * `tool:call` switched off it answers `deny` for every name without a prompt. + * Needs `capabilities.has('tool.request')`. + */ + request( + names: readonly string[], + options?: HostConnectorCallOptions, + ): Promise>; +} + export interface MiniAppLogger { debug(message: string, fields?: JsonObject): void; info(message: string, fields?: JsonObject): void; @@ -89,7 +190,20 @@ export interface MiniAppLogger { error(message: string, fields?: JsonObject): void; } -export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; +/** + * How the call's text ended: in its own turn (`finished` / `error` / `aborted`), or, only with + * `whenBusy`, joined into the turn that was already running (`steered`; that turn's own + * call gets the reply). + */ +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted' | 'steered'; + +/** + * The Desktop composer's follow-up behaviours while a session runs a turn. `queue`: the text runs + * as the next turn and the call follows it. `steer`: the running turn reads the text at its next + * input point (the call resolves `steered`), or, if it answered first, the text runs as the next + * turn and the call follows it. + */ +export type MiniAppAgentWhenBusy = 'queue' | 'steer'; export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; @@ -172,7 +286,7 @@ export interface MiniAppAgentRunResult { 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. */ + /** The model the turn was dispatched with; absent when no turn ran or the text was steered. */ readonly model?: MiniAppAgentModelSelection; /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ readonly status: MiniAppAgentRunStatus; @@ -192,13 +306,17 @@ export interface MiniAppAgentRunResult { * 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. + * - `run.queued`: the call waits at 1-based `position`, for a run slot or, with + * `whenBusy: 'queue'`, in the session queue (a `whenBusy` call can get both); sent before the + * turn starts, so it has no `turnId` and names a session only when continuing one. + * - `message.pending` / `message.steered`: with `whenBusy: 'steer'`, the text was handed to the + * running turn, then read by it; `message.steered` is the call's last event. * - `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. + * - `turn.finished`: last unless the call ended `steered`; 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 @@ -208,6 +326,11 @@ export interface MiniAppAgentRunResult { */ export type MiniAppAgentRunEvent = | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'message.pending' | 'message.steered'; + readonly sessionId: string; + readonly turnId: string; + } | { readonly type: 'turn.started'; readonly sessionId: string; @@ -263,11 +386,72 @@ export type MiniAppAgentRunEvent = readonly type: 'turn.finished'; readonly sessionId: string; readonly turnId: string; - readonly status: MiniAppAgentRunStatus; + readonly status: Exclude; readonly text?: string; readonly error?: { readonly code: string; readonly message: string }; }; +/** `observe({ all: true })` only: the conversation's title, when it has one. */ +interface MiniAppAgentObservedTitle { + readonly title?: string; +} + +type MiniAppAgentRunEventOf = Extract< + MiniAppAgentRunEvent, + { readonly type: T } +>; + +/** + * What an observer receives: the run events without `run.queued` and without asks, which reach + * only the run that owns them. `turn.started.model` is present only when the Host could read it. + * Ignore unknown `type` values. + */ +export type MiniAppAgentObservedEvent = + | (Omit, 'model'> & { + readonly model?: MiniAppAgentModelSelection; + } & MiniAppAgentObservedTitle) + | MiniAppAgentRunEventOf<'text.delta' | 'tool.started' | 'tool.finished'> + | (MiniAppAgentRunEventOf<'message.committed'> & MiniAppAgentObservedTitle) + | (MiniAppAgentRunEventOf<'turn.finished'> & MiniAppAgentObservedTitle); + +/** + * `{ all: true }`: `turn.started`, `message.committed` (the final reply) and `turn.finished` of + * every user conversation, with its `title`. `{ sessionId }`: every progress event of that + * session, turn after turn. Exactly one of the two. + */ +export type MiniAppAgentObserveTarget = { readonly sessionId: string } | { readonly all: true }; + +/** One of the user's conversations; `running` while a turn is in flight. */ +export interface MiniAppAgentSessionSummary { + readonly sessionId: string; + readonly title?: string; + /** The Agent the session runs. */ + readonly agent: string; + readonly status: 'idle' | 'running' | 'error' | 'aborted'; + /** The conversation's project directory (absolute) as recorded; it may no longer exist. */ + readonly workspaceDir: string; + /** True when the conversation has no project and runs in the default workspace. */ + readonly isDefaultWorkspace: boolean; + /** Unix ms. */ + readonly createdAtMs: number; + /** Unix ms. */ + readonly updatedAtMs: number; +} + +export interface MiniAppAgentSessionDetail extends MiniAppAgentSessionSummary { + /** The conversation's current model; absent when the Host has none recorded. */ + readonly model?: MiniAppAgentModelSelection; + /** Only while `status` is `error` and the Host recorded a message. */ + readonly error?: { readonly message: string }; + /** Final text of the latest Agent reply, if any. */ + readonly lastAssistantText?: string; +} + +export interface MiniAppAgentSessions { + /** The 50 most recently updated user conversations (no Mini App, scheduled or sub-Agent ones). */ + readonly sessions: readonly MiniAppAgentSessionSummary[]; +} + export interface MiniAppAgentRunOptions { readonly signal?: AbortSignal; readonly onEvent?: (event: MiniAppAgentRunEvent) => void; @@ -290,12 +474,18 @@ export interface MiniAppAgentError extends Error { * `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'; + /** + * Set on every `BUSY`: the session already has a run in flight, the wait queue is full, the + * per-minute start cap was hit, or `observe` found 4 observers already running. + */ + readonly reason?: 'session_busy' | 'queue_full' | 'rate_limited' | 'observer_limit'; } /** - * Present only when the manifest declares `agentAccess: ["session.run"]`. + * Present when the manifest declares `agentAccess` (`"session.run"` and/or `"session.read"`). + * `run` / `reply` / `answer` / `models` / `agents` / `create` need `session.run`; `observe` and + * `sessions` need `session.read` except for this Mini App's own sessions (see + * docs/agent.md). * 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 @@ -311,7 +501,9 @@ export interface MiniAppAgentError extends Error { * 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`. + * `REQUEST_CANCELLED`. A `whenBusy` call on a session that already has a call in flight shares + * that call's slot. Cancelling a queued `whenBusy` text removes it (`REQUEST_CANCELLED`); a steered + * text cannot be withdrawn (`AGENT_OUTCOME_UNKNOWN`, the running turn goes on). */ export interface MiniAppAgentClient { run( @@ -329,6 +521,11 @@ export interface MiniAppAgentClient { * with `sessionId` is `INVALID_ARGUMENTS`. */ readonly agent?: string; + /** + * Only with `sessionId`: what to do when that session is already running a turn; on an idle + * session the call starts a turn as usual. Omit to reject with `BUSY` (`session_busy`). + */ + readonly whenBusy?: MiniAppAgentWhenBusy; }, options?: MiniAppAgentRunOptions, ): Promise; @@ -374,6 +571,31 @@ export interface MiniAppAgentClient { * agent from `agents()`; a session never changes it. */ create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; + /** + * Watch the user's Agent at work (see `MiniAppAgentObserveTarget`). No request timeout; abort + * `signal` to stop, after which `onEvent` gets nothing more. Resolves when the subscription + * ended: `signal` aborted, the session was deleted, the process is stopping or being replaced, + * or `session.read` was switched off. + * Rejects with a `MiniAppAgentError` when it could not start or failed: `INVALID_ARGUMENTS` (not + * exactly one target), `NOT_PERMITTED`, `SESSION_NOT_FOUND`, retryable `BUSY` past 4 observers + * per Mini App (`reason: 'observer_limit'`) or `AGENT_UNAVAILABLE`. Observers use no run slots + * and no ordinary request budget. Check `context.capabilities?.has('agent.observe')` first. + */ + observe( + target: MiniAppAgentObserveTarget, + options: { + readonly onEvent: (event: MiniAppAgentObservedEvent) => void; + readonly signal?: AbortSignal; + }, + ): Promise; + /** Check `context.capabilities?.has('agent.sessions')` first. */ + sessions(): Promise; + /** + * One conversation with its model and latest reply. `sessionId` is required: Node serves every + * tab, so it has no current chat; a page passes its chat's id (from its own `session()`). + * Check `context.capabilities?.has('agent.session')`. + */ + session(sessionId: string): Promise; } export interface MiniAppLifecycle { @@ -387,10 +609,31 @@ export interface MiniAppContext { readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; readonly signal: AbortSignal; readonly logger: MiniAppLogger; + /** + * Synchronous probe of Host features, fixed per Host version. It says nothing about this + * Mini App's manifest or authorization: `has('agent.run')` is true on a Host that supports the + * Agent API even when `context.agent` is absent because `agentAccess` was not declared. + * Older Hosts do not inject `capabilities`; check `context.capabilities?.has(...)`. + */ + readonly capabilities?: MiniAppHostCapabilities; + /** + * Present when the manifest declares a `hostConnectorAccess` provider; a provider the user + * switched off is missing from `list()` and its calls fail with `CONNECTOR_NOT_PERMITTED`. + */ readonly hostConnector?: HostConnectorClient; + /** Installed MCP and Connector tools, each call judged by the user's permission rules. */ + readonly tool?: MiniAppToolClient; + /** + * Present when the manifest declares `agentAccess: ["session.run"]`; while the user has it + * switched off, calls fail with `NOT_PERMITTED`. + */ readonly agent?: MiniAppAgentClient; } +export interface MiniAppHostCapabilities { + has(name: string): boolean; +} + export interface MiniAppModule { /** Resolve only after the listener accepts connections and every route is installed. */ start(context: MiniAppContext): Promise; From ea6a501547602f617ab7a5a484318843b1b560b9 Mon Sep 17 00:00:00 2001 From: dazhi Date: Mon, 26 Oct 2026 18:32:18 +0800 Subject: [PATCH 3/3] fix(check): require session.run for Agent turns and report each file's missing agentAccess once Review follow-up: - ACCESS_UNDECLARED treated any agentAccess value as enough, so a package declaring only "session.read" could call context.agent.run() unreported although every such call fails. Agent calls are now checked per value: run/answer/reply/cancel/open/models/agents/create need "session.run", sessions()/observe({ all: true })/the page's session() need "session.read", and reading or observing the Mini App's own sessions needs either. A file gets one diagnostic naming every missing value instead of one per rule. - The default miniapp result is built in one place (emptyMiniApp). - Docs: the scan covers .htm; the microphone is granted only to audio-only main-frame requests; an engines bound also withholds mcpEndpoints and fails workspace publishing with MINIAPP_HOST_TOO_OLD; every declared permission can be switched off; page code is not_permitted. Assisted-by: Claude Opus 5.5 (OpenCode) --- AGENTS.md | 5 +++-- README.md | 2 +- README.zh-CN.md | 2 +- docs/client.md | 5 ++++- docs/package-contract.md | 23 ++++++++++++++--------- scripts/lib/package.mjs | 4 ++-- scripts/lib/rules/miniapp.mjs | 7 ++++++- scripts/lib/rules/usage.mjs | 33 +++++++++++++++++++++++---------- test/usage.test.mjs | 28 ++++++++++++++++++++++++++++ 9 files changed, 82 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5eccde01..87c6beef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,9 @@ update one package at `plugins///`. `npm run check` reports it as `ACCESS_UNDECLARED`; do not declare access the app does not use. - `agentAccess` gives an Agent with files, shell, and MCP, and `session.read` exposes every user conversation. Never answer a `permission.requested` with `allowOnce` unless the user chose it. -- The user can switch any declared access off at any time. Handle `NOT_PERMITTED`, - `TOOL_NOT_PERMITTED`, and `NotAllowedError` where the call is made, and keep the app running. +- The user can switch any declared access off at any time. Handle `NOT_PERMITTED` + (`not_permitted` on the page), `TOOL_NOT_PERMITTED`, and `NotAllowedError` where the call is made, + and keep the app running. - 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. diff --git a/README.md b/README.md index 5f410e36..be2d8145 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,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. Apps that use newer capabilities need a newer client, and older versions cannot install them: running Agent tasks or opening in their own window needs MiniMax Code 3.1.2 or later; reading your conversations, calling your installed tools, or using the microphone needs 3.1.5 or later. From 3.1.5 you can switch each of these permissions off per app in the plugin details. +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 need a newer client, and older versions cannot install them: running Agent tasks or opening in their own window needs MiniMax Code 3.1.2 or later; reading your conversations, calling your installed tools, or using the microphone needs 3.1.5 or later. From 3.1.5 you can switch off each permission an app declares, one by one, in its plugin details. ### 1. Download diff --git a/README.zh-CN.md b/README.zh-CN.md index 62f8b12d..a3b533be 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -112,7 +112,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 ### 使用要求 -需要支持 MiniApp 的 MiniMax Code 桌面端版本。各作品的 README 会说明已验证的客户端版本、操作系统、所需配置和已知限制,兼容性以具体作品为准。使用较新能力的作品需要较新的客户端,旧版本无法安装:运行 Agent 任务、在独立窗口中打开需要 MiniMax Code 3.1.2 或更高版本;读取你的会话、调用你已安装的工具或使用麦克风需要 3.1.5 或更高版本。从 3.1.5 起,你可以在插件详情里按作品逐项关闭这些权限。 +需要支持 MiniApp 的 MiniMax Code 桌面端版本。各作品的 README 会说明已验证的客户端版本、操作系统、所需配置和已知限制,兼容性以具体作品为准。使用较新能力的作品需要较新的客户端,旧版本无法安装:运行 Agent 任务、在独立窗口中打开需要 MiniMax Code 3.1.2 或更高版本;读取你的会话、调用你已安装的工具或使用麦克风需要 3.1.5 或更高版本。从 3.1.5 起,你可以在每个作品的插件详情里逐项关闭它声明的权限。 ### 1. 下载 diff --git a/docs/client.md b/docs/client.md index 08d62a83..df9e9111 100644 --- a/docs/client.md +++ b/docs/client.md @@ -26,7 +26,10 @@ Mini App's grants: `has('agent.run')` is true even without `agentAccess`. Earlie From MiniMax Code 3.1.5 the Host decides every browser permission request from a Mini App page. Sanitized clipboard writes, fullscreen, pointer lock, and keyboard lock work without a declaration. The microphone works only when `miniapp.json` declares `deviceAccess: ["microphone"]` and the user -has not switched it off; otherwise `getUserMedia` rejects with `NotAllowedError`. Camera, +has not switched it off; otherwise `getUserMedia` rejects with `NotAllowedError`. Only audio-only +requests from the page's main frame are granted: asking for audio and video together, or calling +from an iframe, is denied. `navigator.permissions.query({ name: 'microphone' })` reports `granted` +only after a granted `getUserMedia`, so call `getUserMedia` instead of querying first. Camera, geolocation, notifications, and everything else a browser would prompt for are denied and cannot be declared yet. Earlier clients granted these requests silently. diff --git a/docs/package-contract.md b/docs/package-contract.md index fec221ca..5e49bfe4 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -130,12 +130,15 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, judged by the user's permission rules. See `docs/tool.md`. Requires MiniMax Code 3.1.5 or later. - `deviceAccess` is optional. The only allowed value is `["microphone"]`, which lets the page call `getUserMedia({ audio })` and `window.mcode.miniapp.speech.transcribe` (see `docs/client.md`). - Requires MiniMax Code 3.1.5 or later. + Only audio-only requests from the page's main frame are granted: a request that also asks for + video, or comes from an iframe, is still denied. Requires MiniMax Code 3.1.5 or later. - `engines` is optional: `{ "mcode": ">=MAJOR.MINOR.PATCH" }`, nothing else. A client at 3.1.5 or later but older than the bound keeps the plugin with its Skills and MCP servers, and withholds - the Mini App. Clients before 3.1.5 do not know the field and reject the whole package, so the - bound only matters above 3.1.5. Write it only when the package needs a specific newer client; it - is not required for the features in the table below. Requires MiniMax Code 3.1.5 or later. + the Mini App and its `mcpEndpoints`; publishing such a package from a workspace fails with + `MINIAPP_HOST_TOO_OLD`. Clients before 3.1.5 do not know the field and reject the whole package, + so the bound only matters above 3.1.5. Write it only when the package needs a specific newer + client; it is not required for the features in the table below. Requires MiniMax Code 3.1.5 or + later. ### Permission switches @@ -224,19 +227,21 @@ exists. A manifest field cannot be feature-detected: declaring it is what raises ## Declared access must match the code -`npm run check` scans the files that run in the page or in Node (`.js`, `.mjs`, `.cjs`, `.html` -under `artifacts.client` and `artifacts.node`, outside `node_modules`) for calls that need a grant -the manifest does not make, and reports them as `ACCESS_UNDECLARED`. +`npm run check` scans the files that run in the page or in Node (`.js`, `.mjs`, `.cjs`, `.html`, +`.htm` under `artifacts.client` and `artifacts.node`, outside `node_modules`) for calls that need a +grant the manifest does not make, and reports them as `ACCESS_UNDECLARED`. | Call | Needs | Level | | --- | --- | --- | -| `context.agent.*` or page `agent.*` | `agentAccess` | error | +| `context.agent` / page `agent` `run`, `answer`, `reply`, `cancel`, `open`, `models`, `agents`, `create` | `agentAccess` `"session.run"` | error | | `agent.sessions()`, `agent.observe({ all: true })`, page `agent.session()` without an id | `agentAccess` `"session.read"` | error | +| `agent.session(id)`, `agent.observe({ sessionId })` on the Mini App's own sessions | either `agentAccess` value | error | | `context.tool.*`, page `tool.request()` | `toolAccess: ["call"]` | error | | `speech.transcribe()` | `deviceAccess: ["microphone"]` | error | | `getUserMedia()` | `deviceAccess: ["microphone"]` | error when the package's minimum is 3.1.5 or later; warning otherwise | -The first four never work without the grant, on any client. `getUserMedia` worked without a grant +The Agent rows are reported once per file, naming every missing value. The first five rows never +work without the grant, on any client. `getUserMedia` worked without a grant before MiniMax Code 3.1.5, and 3.1.5 and later deny it with `NotAllowedError`. A package whose minimum is below 3.1.5 still installs on those earlier clients, so the check only warns; declaring `deviceAccess` fixes the call and raises the minimum to 3.1.5. Camera, geolocation, notifications, diff --git a/scripts/lib/package.mjs b/scripts/lib/package.mjs index bc4c6449..59a8de35 100644 --- a/scripts/lib/package.mjs +++ b/scripts/lib/package.mjs @@ -6,7 +6,7 @@ import { checkFiles } from './rules/files.mjs'; import { checkImageFile, checkLayout, checkNameMatchesDirectory } from './rules/layout.mjs'; import { checkManifest } from './rules/manifest.mjs'; import { checkMcode } from './rules/mcode.mjs'; -import { checkMiniApp } from './rules/miniapp.mjs'; +import { checkMiniApp, emptyMiniApp } from './rules/miniapp.mjs'; import { checkNode } from './rules/node.mjs'; import { checkReadme } from './rules/readme.mjs'; import { checkAccessUsage } from './rules/usage.mjs'; @@ -32,7 +32,7 @@ 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: [], features: [], access: { agent: [], tool: false, microphone: false } }; + let resolved = emptyMiniApp(); if (miniapp.present) resolved = await checkMiniApp(report, { packageDir: dir, manifest: miniapp.value }); features.push(...resolved.features); diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index 413cfe06..c181c579 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -29,8 +29,13 @@ import { isCoveredBy, normalizePluginPath, normalizeRoutePath } from '../paths.m const FILE = 'miniapp/miniapp.json'; +// What a package without a readable miniapp.json resolves to: no payload, no features, no access. +export function emptyMiniApp() { + return { nodeRoots: [], clientRoots: [], features: [], access: { agent: [], tool: false, microphone: false } }; +} + export async function checkMiniApp(report, { packageDir, manifest }) { - const resolved = { nodeRoots: [], clientRoots: [], features: [], access: { agent: [], tool: false, microphone: false } }; + const resolved = emptyMiniApp(); if (!isRecord(manifest)) { report.error('MINIAPP_NOT_OBJECT', 'miniapp.json must be a JSON object', FILE); return resolved; diff --git a/scripts/lib/rules/usage.mjs b/scripts/lib/rules/usage.mjs index 75f376c4..7213493b 100644 --- a/scripts/lib/rules/usage.mjs +++ b/scripts/lib/rules/usage.mjs @@ -8,17 +8,12 @@ import { compareVersions } from '../version.mjs'; const SCANNED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.html', '.htm']); // Mini App APIs that never work without their declaration, on any client. +const AGENT_PREFIX = String.raw`\b(?:context|miniapp)\??\.agent\??\.`; +const AGENT_RUN_CALL = new RegExp(String.raw`${AGENT_PREFIX}(?:run|answer|reply|cancel|open|models|agents|create)\s*\(`, 'u'); +const AGENT_OWN_SESSION_CALL = new RegExp(String.raw`${AGENT_PREFIX}(?:observe|session)\s*\(`, 'u'); +const USER_SESSION_CALL = /\bagent\??\.sessions\s*\(|\bagent\??\.observe\s*\(\s*\{\s*all\s*:\s*(?:true|!0)|\bagent\??\.session\s*\(\s*\)/u; + const API_RULES = [ - { - pattern: /\b(?:context|miniapp)\??\.agent\??\.(?:run|answer|reply|cancel|open|models|agents|create|observe|sessions|session)\s*\(/u, - granted: (access) => access.agent.length > 0, - message: 'calls the Agent API but miniapp.json declares no agentAccess; context.agent is absent and page calls reject with not_permitted', - }, - { - pattern: /\bagent\??\.sessions\s*\(|\bagent\??\.observe\s*\(\s*\{\s*all\s*:\s*(?:true|!0)|\bagent\??\.session\s*\(\s*\)/u, - granted: (access) => access.agent.includes('session.read'), - message: 'lists sessions, observes every session, or reads the chat the page is open in, which needs agentAccess "session.read"; without it the call rejects with not_permitted', - }, { pattern: /\b(?:context|miniapp)\??\.tool\??\.(?:call|list|request)\s*\(/u, granted: (access) => access.tool, @@ -38,6 +33,7 @@ export async function checkAccessUsage(report, { packageDir, roots, access, mini for (const relativePath of await collectPayloadFiles(packageDir, roots, SCANNED_EXTENSIONS)) { const source = await readText(path.join(packageDir, ...relativePath.split('/'))); if (source === undefined) continue; + checkAgentUsage(report, source, access.agent, relativePath); for (const rule of API_RULES) { if (!rule.granted(access) && rule.pattern.test(source)) report.error('ACCESS_UNDECLARED', rule.message, relativePath); } @@ -45,6 +41,23 @@ export async function checkAccessUsage(report, { packageDir, roots, access, mini } } +// One diagnostic per file, naming every agentAccess value the file's calls need and lack. +function checkAgentUsage(report, source, granted, relativePath) { + const missing = []; + if (!granted.includes('session.run') && AGENT_RUN_CALL.test(source)) missing.push('"session.run"'); + if (!granted.includes('session.read') && USER_SESSION_CALL.test(source)) missing.push('"session.read"'); + // Reading or observing the Mini App's own sessions works with either value. + if (missing.length === 0 && granted.length === 0 && AGENT_OWN_SESSION_CALL.test(source)) { + missing.push('"session.run" or "session.read"'); + } + if (missing.length === 0) return; + report.error( + 'ACCESS_UNDECLARED', + `calls Agent APIs that need agentAccess ${missing.join(' and ')}; without it context.agent is absent or the calls reject with not_permitted`, + relativePath, + ); +} + // Clients before the gate granted the microphone silently, so the call fails on every client // that installs the package only when the package already needs a gated client. function reportGetUserMedia(report, relativePath, gated) { diff --git a/test/usage.test.mjs b/test/usage.test.mjs index 73eb9090..e60f4814 100644 --- a/test/usage.test.mjs +++ b/test/usage.test.mjs @@ -53,6 +53,34 @@ test('usage: listing sessions, observing every session, and reading the page cha } }); +test('usage: running turns needs session.run even when session.read is declared', async () => { + const diagnostics = await run( + { 'miniapp/node/agent.mjs': 'export const go = (context) => context.agent.run({ text: "x" });\n' }, + { access: { ...NONE, agent: ['session.read'] } }, + ); + assert.deepEqual(codes(diagnostics, 'error'), ['ACCESS_UNDECLARED']); + assert.match(diagnostics[0].message, /"session\.run"/u); +}); + +test('usage: one Agent diagnostic per file names every missing agentAccess value', async () => { + const pageChat = await run({ 'miniapp/client/app.js': 'await window.mcode.miniapp.agent.session();\n' }); + assert.equal(pageChat.length, 1); + assert.match(pageChat[0].message, /"session\.read"/u); + const both = await run({ 'miniapp/client/app.js': 'await miniapp.agent.run({ text });\nawait miniapp.agent.sessions();\n' }); + assert.equal(both.length, 1); + assert.match(both[0].message, /"session\.run" and "session\.read"/u); +}); + +test('usage: reading or observing its own sessions needs either agentAccess value', async () => { + const source = 'context.agent.observe({ sessionId }, { onEvent });\n'; + const none = await run({ 'miniapp/node/watch.mjs': source }); + assert.deepEqual(codes(none, 'error'), ['ACCESS_UNDECLARED']); + assert.match(none[0].message, /"session\.run" or "session\.read"/u); + for (const agent of [['session.run'], ['session.read']]) { + assert.deepEqual(await run({ 'miniapp/node/watch.mjs': source }, { access: { ...NONE, agent } }), [], agent[0]); + } +}); + test('usage: tool calls without toolAccess are errors', async () => { for (const source of ['await context.tool.call(name, {});\n', 'const { tools } = await context.tool?.list();\n', 'await window.mcode.miniapp.tool.request([name]);\n']) { const diagnostics = await run({ 'miniapp/node/tools.mjs': source });