diff --git a/AGENTS.md b/AGENTS.md index e15047b..b95e135 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,12 @@ package at `plugins///`; the rest change the checks 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` or a browser permission such as - `getUserMedia`, `docs/agent.md` before declaring `agentAccess`, and `docs/tool.md` before - declaring `toolAccess`. + `getUserMedia` or `Notification`, `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, conversations read, tools called, microphone use, - where state is stored). When the package uses a feature with a minimum client version + network hosts, spawned processes, Agent turns, conversations read, tools called, microphone, + camera, and notification use, Host navigation read, 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. @@ -36,7 +36,11 @@ package at `plugins///`; the rest change the checks - stdout and stdin belong to the Host. Log through `context.logger`. - The Node entry is ESM with a named export `start(context)`. Resolve `start` only after the listener accepts connections on `context.listen`. Return `{ dispose }`; `dispose` closes - everything the entry started: server, timers, child processes, streams, file handles. + everything the entry started: server, timers, child processes, streams, file handles, within + 2 seconds. +- While no page is visible, the Host may stop the Node process or reload the page without notice + or callback. Write durable state as it changes and save view state on `visibilitychange`; polling + and heartbeats keep nothing alive. - Bind only `context.listen.host` / `context.listen.port`, with a `node:http` server (or a framework on top of it); `https`, `http2`, `tls`, raw `net`, or a child process on that port never becomes ready. Serve `surface.path` plus your own routes, none under `/__mavis/`. @@ -46,7 +50,8 @@ package at `plugins///`; the rest change the checks 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 + `toolAccess`, `deviceAccess` (`microphone`, `camera`, `notifications`), `hostAccess` + (`navigation.read`). 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. @@ -62,16 +67,23 @@ package at `plugins///`; the rest change the checks ## Review policy Maintainers gate on three things: manifests pass `npm run check`; the README describes real -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 +behaviour (files, network, processes, Agent turns, conversations read, tools called, device use, +tested environment); the package cannot damage Host or user data. Everything else is a suggestion. ## Change the checks or the docs `scripts/` mirrors the MiniMax Code package reader; `docs/` publishes the same contract. -- A minimum in `FEATURE_MIN_HOST_VERSION` is the first released MiniMax Code that accepts the - field. Preview builds ship features early and are not evidence. +- A minimum in `FEATURE_MIN_HOST_VERSION` is the first MiniMax Code release that accepts the + field and has installers for both macOS and Windows on the production CDN. A tag, a preview + build, or a release on one platform only is not evidence: 3.1.5 shipped no installers and 3.1.8 + none for Windows. Check that `MiniMax%20Code--arm64.dmg` and + `MiniMax%20Code%20Setup%20.exe` answer 200 under both + `https://filecdn.minimax.chat/public/minimax-agent-prod/release/` and + `https://file.cdn.minimax.io/public/minimax-agent-prod/release/`. +- A feature that ships first in the next release uses `NEXT_RELEASE` in `scripts/lib/contract.mjs`. + Its PR stays a draft until that release's installers pass the check above. - Change `scripts/lib/contract.mjs`, the rule, its `test/.test.mjs`, and the matching `docs/` page together, and update the docs' `Verified against` line. The checker stays dependency-free. @@ -83,14 +95,15 @@ suggestion. - `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: `capabilities`, browser permissions, `message.append`, `agent`, - `speech.transcribe`, `tool.request`, `window` +- `docs/runtime.md` — `start(context)`, `dispose`, logger, lifecycle and reclaim, loopback + authentication +- `docs/client.md` — page APIs: `capabilities`, browser permissions (microphone, camera, + notifications), `message.append`, `agent`, `speech.transcribe`, `tool.request`, `host`, `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, reading conversations, calling tools, microphone + turns, reading conversations, calling tools, microphone and camera, notifications, Host navigation - `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 27d282c..36d259d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ We welcome tools, games, and other interesting apps you have built for MiniMax C 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`, `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`). +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, microphone, camera, or notification use, and whether it reads which MiniMax Code page you are on. 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`, `hostAccess`, 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 8487a00..09f97a2 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -6,7 +6,7 @@ 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/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”)。 +3. 添加简短的英文 `README.md`,可另附 `README.zh-CN.md` 并互相链接。说明用途、安装与使用方式、已验证的客户端版本和系统,以及需要的配置、文件访问、网络请求、会运行的 Agent 任务、会读取的会话、会调用的工具,是否使用麦克风、摄像头或系统通知,以及是否读取你当前所在的 MiniMax Code 页面。欢迎附上截图或 GIF。`miniapp/miniapp.json` 中只声明代码实际用到的权限;代码调用了未声明的能力时,`npm run check` 会报出来。如果用到了需要较新客户端的能力(如 `agentAccess`、`toolAccess`、`deviceAccess`、`hostAccess`、`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 735debd..a99f093 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,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 off each permission an app declares, one by one, in its 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.6 or later; using the camera or showing system notifications needs 3.1.9 or later; following which MiniMax Code page you are on needs 3.1.11 or later. From 3.1.6 you can switch off each permission an app declares, one by one, in its plugin details. ### 1. Download @@ -162,7 +162,7 @@ The installed manifest must be at: The author directory (`amszuidas/`) only groups contributions in the repository. Copy the plugin directory directly into the client's `plugins/` directory, without that extra author level. -These packages use **manual installation**. The client's GitHub plugin import feature does not support these MiniApp packages. +These packages use **manual installation**. Importing them with the client's GitHub plugin import does not work: the client downloads the whole repository, which is larger than its 128 MiB limit. ### 3. Open diff --git a/README.zh-CN.md b/README.zh-CN.md index 6ecb50a..39b89af 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -129,7 +129,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.6 或更高版本;使用摄像头或发送系统通知需要 3.1.9 或更高版本;读取你当前所在的 MiniMax Code 页面需要 3.1.11 或更高版本。从 3.1.6 起,你可以在每个作品的插件详情里逐项关闭它声明的权限。 ### 1. 下载 @@ -162,7 +162,7 @@ git clone https://github.com/MiniMax-AI/MiniMax-Code-MiniApps.git 作者目录(`amszuidas/`)只用于仓库归类。复制到客户端时,将插件目录直接放入 `plugins/`,不要多套一层作者目录。 -这些插件包采用**手动安装**方式,客户端的 GitHub 插件导入功能目前不支持这类 MiniApp 包。 +这些插件包采用**手动安装**方式。用客户端的 GitHub 插件导入功能无法安装:客户端会下载整个仓库,而本仓库已超过它 128 MiB 的上限。 ### 3. 打开 diff --git a/docs/agent.md b/docs/agent.md index 2b6b3e7..9a70652 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,7 +1,7 @@ # Mini App Agent API -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"`. +Verified against MiniMax Code 3.1.11. Clients before 3.1.2 reject packages that declare +`agentAccess`, and clients before 3.1.6 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, @@ -22,7 +22,7 @@ at runtime; a page that only prepares text for the user to send should use `context.agent` is absent and page calls reject with `not_permitted`. - `"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 +- `"session.read"` (MiniMax Code 3.1.6 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. @@ -30,14 +30,14 @@ at runtime; a page that only prepares text for the user to send should use 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 +- The user can switch either value off in the plugin details (MiniMax Code 3.1.6 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. + `agent.sessions`, and `agent.session` exist from 3.1.6. ## Sessions @@ -86,7 +86,7 @@ Node calls take an options object `{ signal?, onEvent? }`; page calls take `{ on ### Follow-ups while the session is busy (`whenBusy`) -From MiniMax Code 3.1.5, `run({ sessionId, text, whenBusy })` decides what happens when that session +From MiniMax Code 3.1.6, `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. @@ -111,7 +111,7 @@ to free the slot. `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 + (MiniMax Code 3.1.6 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`. @@ -179,7 +179,7 @@ Ignore any `type` you do not know; new types may be added. ## 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 +These calls exist from MiniMax Code 3.1.6. 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. diff --git a/docs/client.md b/docs/client.md index df9e911..5aa29d7 100644 --- a/docs/client.md +++ b/docs/client.md @@ -1,6 +1,6 @@ # Mini App page APIs -Verified against MiniMax Code 3.1.6. +Verified against MiniMax Code 3.1.11. 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 @@ -13,25 +13,55 @@ scripts out of that document: anything running there shares the page's access to ## `miniapp.capabilities.has(name)` -From MiniMax Code 3.1.5, `window.mcode.miniapp.capabilities.has(name)` answers synchronously whether +From MiniMax Code 3.1.6, `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 +`agent.session`, `speech.transcribe`, `tool`, `tool.request`, `window`, `hostConnector`, and from +3.1.11 `host.ui.getState`, `host.ui.subscribe`, `host.navigation.getState`, +`host.navigation.subscribe`, `host.navigation.open`. 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`. 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. +From MiniMax Code 3.1.6 the Host decides every browser permission request from a Mini App page and +never shows a browser prompt. Sanitized clipboard writes, fullscreen, pointer lock, and keyboard +lock work without a declaration. The microphone, the camera, and system notifications work only when +`miniapp.json` declares their `deviceAccess` value (`docs/package-contract.md`) and the user has not +switched it off, and only from the page's main frame. Geolocation, clipboard reads, screen +capture, and everything else a browser would prompt for are always denied. Earlier clients granted +these requests silently. + +- **Microphone** (`"microphone"`): `getUserMedia({ audio: true })`; without the grant it rejects with + `NotAllowedError`. Call `getUserMedia` instead of querying first: from 3.1.9 + `navigator.permissions.query({ name: 'microphone' })` reports whether the value is declared and + switched on, but 3.1.6 to 3.1.8 report `granted` only after a granted `getUserMedia`. +- **Camera** (`"camera"`, MiniMax Code 3.1.9 or later): `getUserMedia({ video: true })`. A request + for audio and video together needs both values and fails with `NotAllowedError` when either is + missing or switched off, so request them separately when the page can work with one. The operating + system may also ask once whether MiniMax Code may use the camera; a refusal there makes + `getUserMedia` fail as well. The Host shows no camera indicator: show your own while the stream is + open, and stop its tracks as soon as the page is done. +- **Notifications** (`"notifications"`, MiniMax Code 3.1.9 or later): `new Notification(title, { body })`. + They appear as MiniMax Code's own notifications. `Notification.permission` reads `granted` when + the value is declared and switched on, and `denied` otherwise, never `default`; + `Notification.requestPermission()` resolves at once without a prompt. After the user switches it + off, notifications are not shown; listen for `error` if the page needs to know. + +A click on a notification counts as a user gesture inside its `click` handler, so the page can bring +itself back with `window.focus()` (see [`miniapp.window`](#miniappwindow-proposed)): + +```js +const notice = new Notification('Build finished', { body: 'All 42 checks passed.' }); +notice.addEventListener('click', () => { + window.mcode?.miniapp?.window?.focus().catch(() => {}); +}); +``` + +A popped-out page shows and focuses its window; a page in the side panel brings MiniMax Code forward +and selects its tab. This works only while the page that created the notification is still open: +after that page closes or reloads, clicking its notification does nothing. ## `miniapp.message.append(text)` @@ -66,14 +96,14 @@ async function addToChat(text, status) { ## `miniapp.agent` -Runs Agent turns from the page, and from MiniMax Code 3.1.5 also reads and observes the user's +Runs Agent turns from the page, and from MiniMax Code 3.1.6 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? })` +From MiniMax Code 3.1.6, `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 @@ -97,10 +127,72 @@ turns one utterance the page recorded into text with the Host's speech recogniti ## `miniapp.tool.request(names)` -From MiniMax Code 3.1.5, a page whose manifest declares `toolAccess: ["call"]` can ask the user, +From MiniMax Code 3.1.6, 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.host` + +From MiniMax Code 3.1.11, `window.mcode.miniapp.host` reads MiniMax Code's display preferences and, +with `hostAccess`, which part of the app the user is on, and opens a few Host pages. Feature-detect +`window.mcode?.miniapp?.host`; without it, use the Mini App's own defaults. The Host does not style +or translate the page for you. + +| Call | Resolves or delivers | Needs | +| --- | --- | --- | +| `host.ui.getState()` | `{ theme, language }` | Nothing | +| `host.ui.subscribe(listener, { onError }?)` | The full state at once, then every change. Returns `{ dispose() }`. | Nothing | +| `host.navigation.getState()` | `{ key, view, overlay }` | `hostAccess: ["navigation.read"]`, its switch on, and the page visible | +| `host.navigation.subscribe(listener, { onError }?)` | The full state at once, then every change. Returns `{ dispose() }`. | The same; paused while the page is hidden | +| `host.navigation.open(target)` | `{ status: 'opened' }` or `{ status: 'cancelled', reason }` | A user gesture | + +```js +const host = window.mcode?.miniapp?.host; +const display = host?.ui.subscribe( + ({ theme, language }) => { + document.documentElement.dataset.theme = theme; + document.documentElement.lang = language; + }, + { onError: ({ code }) => console.warn('Host preferences unavailable:', code) }, +); +addEventListener('pagehide', () => display?.dispose(), { once: true }); + +settingsButton.addEventListener('click', () => { + host?.navigation.open({ id: 'settings', section: 'appearance' }).catch(({ code }) => showError(code)); +}); +``` + +- `theme` is the theme in effect, `light` or `dark`; a "follow the system" setting is already + resolved. `language` is the interface language in effect, currently `en` or `zh-Hans`. A new + language setting takes effect, and is reported, only after MiniMax Code restarts. +- `view` is `home`, `conversation`, `plugins`, `schedule`, or `other`. `overlay` is + `{ type: 'settings' }` while settings are open, otherwise `null`. `key` changes with every + navigation, also when the user returns to a view they just left. It is an opaque value for this + page only: it contains no session ID or URL, and `open` does not accept it. Navigating does not + change the conversation the Mini App is bound to. +- Navigation is delivered only while the page is visible: `getState()` rejects while it is hidden, + and a subscription pauses and, once the page is shown again, delivers only the latest state. A + page in the side panel still counts as visible while settings cover it. +- Call `open(target)` directly in a click or key handler; it checks + `navigator.userActivation.isActive`. Targets: `{ id: 'home' }`, `{ id: 'plugins' }`, + `{ id: 'schedule' }`, `{ id: 'settings', section }` with an optional `section` of `'appearance'` + or `'language'`, and `{ id: 'session', sessionId, source: 'local' }`. Paths and URLs are not + accepted. Opening a session whose ID the Mini App already holds needs no `"session.read"` and + grants no access to it. A session that does not exist and one the Mini App may not see both fail + with `target_unavailable`; any `source` but `'local'` fails with `unsupported_source`. +- `opened` means the page is shown, not that its data has loaded. `cancelled` with + `user_cancelled` means the user chose to stay on unsaved work; `superseded` means another + navigation replaced the request. +- Failures reject with a plain object `{ code, message }`, not an `Error`: read `code`, and do not + test `instanceof Error`. `code` is `invalid_request`, `not_permitted`, `host_unavailable`, + `unsupported_source`, `unsupported_target`, `target_unavailable`, or `result_unknown`. + Subscriptions report the same object through `onError` when they fail to start or end. +- `result_unknown`, which `open` also returns after 60 seconds without an answer, means the outcome + is unknown. Do not repeat `open` automatically. +- A page holds at most 64 Host subscriptions; dispose them when done. A subscription ended by the + user switching `navigation.read` off, or by `host_unavailable`, does not resume: subscribe again + later. + ## `miniapp.window` (proposed) Present on MiniMax Code 3.1.2 or later. **Proposed:** the shape may still change. Feature-detect @@ -127,14 +219,21 @@ if (handle) { `transparent`, `minWidth`, and `minHeight` can only be declared in `surface.window` (`docs/package-contract.md`), and a patch containing them is rejected. - Changing `mode`, and `focus()`, must come from a user gesture (for example, synchronously inside - a click handler); otherwise they reject with `not_permitted`. + a click handler, or in the `click` handler of a notification the page created); otherwise they + reject with `not_permitted`. +- When `getState()` includes `modeChangeReloadsPage: true`, changing `mode` reloads the page. The + window `id` and the Node process stay, but the document, its JavaScript state, streams, and + pending promises do not. Save what the page needs before calling `update({ mode })`, do not put + required work after it, and read the state again when the new page loads. MiniMax Code asks the + user to confirm the switch first; when they cancel, `update` resolves with the unchanged state, so + check `mode` instead of assuming it changed. - `alwaysOnTop: true` and `clickThrough: true` are allowed only when `surface.window` declares that field `true`. `width` / `height` are rejected when the window is not resizable. `x` / `y` are rejected when the window would not overlap any display. - Several `update` calls in one animation frame are merged into one request and share its outcome. - Failures reject with an `Error` whose `message` is `inline_mode` (only `mode` may change while - inline), `not_permitted`, `busy` (a `mode` change is already in progress), `invalid_request`, or - `result_unknown`. + inline), `not_permitted`, `busy` (a confirmation, `mode` change, or page reload is already in + progress), `invalid_request`, or `result_unknown`. Pitfalls: @@ -146,5 +245,6 @@ Pitfalls: scroll inside the page. - Leaving `clickThrough` on makes the whole window, including its own controls, unclickable. Turn it on only for the state that needs it. -- `update({ visible: false })` hides the window but the page keeps running. +- `update({ visible: false })` hides the window but the page keeps running. A hidden or minimized + window counts as hidden, so the Host may reload its page (see "Lifecycle" in `docs/runtime.md`). - The window title is always the plugin's `displayName`. diff --git a/docs/package-contract.md b/docs/package-contract.md index 5e49bfe..9e2f189 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -1,6 +1,6 @@ # Mini App package contract -Verified against MiniMax Code 3.1.6. +Verified against MiniMax Code 3.1.11. 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` @@ -52,7 +52,7 @@ committed file. | `darkIcon` | Optional; same rules as `icon`. | | `category` | One of `Office`, `Studio`, `Design & Sites`, `Code`, `Business`, `Sales`, `Productivity`, `Science & Healthcare`, `Education`, `Other`. | | `exampleQueries` | Array of non-empty strings. Provide at least one; the Agent uses them to open the Mini App by name. | -| `apps` | Array of `*.app.json` paths. Locally installed packages ignore this field; use `[]`. | +| `apps` | Array of `*.app.json` paths; each file must exist and be `{ "schemaVersion": 1, "provider": "" }` (plus an optional `$schema`), with `provider` matching `^[a-z0-9_-]{1,64}$` and unique across the files. Each names a Connector app the plugin works with. From MiniMax Code 3.1.9 the plugin details list them with their connection status, and the client skips an entry that is missing, invalid, or repeats a provider instead of refusing the package; earlier clients ignore the field. | | `mcpServers` | Array of `*.mcp.json` paths; each file must exist. These are MCP servers the plugin offers to the Agent, distinct from `mcpEndpoints` in `miniapp.json`. | | `skills` | Array of `skills//SKILL.md` paths; each file must exist. | | `hooks` | Optional array of `*.json` paths; each file must exist. Contents are validated by MiniMax Code at install time. | @@ -97,7 +97,7 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, } ``` -- `schemaVersion` is `1`. Unknown top-level fields are rejected: MiniMax Code 3.1.5 and later report +- `schemaVersion` is `1`. Unknown top-level fields are rejected: MiniMax Code 3.1.6 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/`. @@ -105,7 +105,9 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, installs. Any `node_modules` directory is excluded from payloads. - `runtime.kind` is `process`. `runtime.entry` ends in `.js`, `.mjs`, or `.cjs`, exists, and lies inside one of `artifacts.node`. Use `.mjs`: this repository checks the entry for an ESM `start` - export (see `docs/runtime.md`). `runtime.lifecycle` is `on-demand` or omitted. + export (see `docs/runtime.md`). `runtime.lifecycle` is `on-demand` or omitted. The Host may stop + the process and reload the page while the Mini App is not visible (see "Lifecycle" in + `docs/runtime.md`). - `surface.path` is the route the Node entry serves the page on. It is relative to the Host and must not contain an origin, query, fragment, or backslash. A missing leading `/` is added. - `surface.window` is optional; see [`surface.window`](#surfacewindow). @@ -121,30 +123,42 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, - `agentAccess` is optional: an array of `"session.run"` (run Agent turns) and `"session.read"` (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. + declaring it. Requires MiniMax Code 3.1.2 or later; `"session.read"` requires 3.1.6 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`). - 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 + judged by the user's permission rules. See `docs/tool.md`. Requires MiniMax Code 3.1.6 or later. +- `deviceAccess` is optional: an array of the browser permissions the page may hold. Absent or `[]` + means none. See "Browser permissions" in `docs/client.md`. + - `"microphone"`: `getUserMedia({ audio })` and `window.mcode.miniapp.speech.transcribe`. + Requires MiniMax Code 3.1.6 or later. + - `"camera"`: `getUserMedia({ video })`. Requires MiniMax Code 3.1.9 or later. + - `"notifications"`: `new Notification(...)`. Requires MiniMax Code 3.1.9 or later. + + A request for audio and video together needs both `"microphone"` and `"camera"`. Requests from an + iframe are denied, and so is every other permission a browser would prompt for. Clients that + predate a value reject the package with `MINIAPP_DEVICE_ACCESS_INVALID`. +- `hostAccess` is optional. The only allowed value is `["navigation.read"]`, which lets the page + read which part of MiniMax Code the user is looking at through + `window.mcode.miniapp.host.navigation` (see `docs/client.md`). Reading the Host's theme and + language, and opening Host pages, need no declaration. Other values fail with + `MINIAPP_HOST_ACCESS_INVALID`. Requires MiniMax Code 3.1.11 or later. +- `engines` is optional: `{ "mcode": ">=MAJOR.MINOR.PATCH" }`, nothing else. A client at 3.1.6 or later but older than the bound keeps the plugin with its Skills and MCP servers, and withholds 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 + `MINIAPP_HOST_TOO_OLD`. Clients before 3.1.6 do not know the field and reject the whole package, + so the bound only matters above 3.1.6. 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.6 or later. ### Permission switches -From MiniMax Code 3.1.5 the user can switch off, item by item in the plugin details, what the +From MiniMax Code 3.1.6 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 +`"call"`, each `deviceAccess` value, and `hostAccess` `"navigation.read"`. Everything starts on, +with no first-use prompt and no browser permission 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: @@ -154,6 +168,9 @@ and their calls fail until the user switches the item back on: | `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. | +| `camera` | The next `getUserMedia` that asks for video fails with `NotAllowedError`; a stream already open keeps running until the page stops it. | +| `notifications` | New notifications are not shown, and `Notification.permission` reads `denied`. | +| `navigation.read` | Navigation subscriptions end through `onError`; `getState()` and new subscriptions fail with `not_permitted`. Subscribe again once it is back on. | 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. @@ -208,21 +225,26 @@ 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.5 | +| `agentAccess` with `"session.read"` | `miniapp/miniapp.json` | 3.1.6 | | `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 | +| `deviceAccess` | `miniapp/miniapp.json` | 3.1.6 | +| `deviceAccess` with `"camera"` or `"notifications"` | `miniapp/miniapp.json` | 3.1.9 | +| `engines` | `miniapp/miniapp.json` | 3.1.6, or its own bound when higher | +| `hostAccess` | `miniapp/miniapp.json` | 3.1.11 | | `surface.window` | `miniapp/miniapp.json` | 3.1.2 | -| `toolAccess` | `miniapp/miniapp.json` | 3.1.5 | +| `toolAccess` | `miniapp/miniapp.json` | 3.1.6 | -A 3.1.4 preview build accepted `"session.read"`, but the released 3.1.4 rejects it. +Each minimum is the first MiniMax Code release with installers for both macOS and Windows. 3.1.5 +shipped no installers and 3.1.8 none for Windows, so neither is ever a minimum. Preview builds do +not count either: they can accept a field before the release that ships 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. +`- MiniMax Code 3.1.6 on macOS 15`. `npm run check` warns until it finds one there. A pre-release +such as `3.1.11-preview.4900` ranks below `3.1.11`. 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 +it: `capabilities?.has('agent.observe')` (MiniMax Code 3.1.6 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 @@ -238,15 +260,16 @@ grant the manifest does not make, and reports them as `ACCESS_UNDECLARED`. | `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 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, -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. +| page `host.navigation.getState()`, `host.navigation.subscribe()` | `hostAccess: ["navigation.read"]` | error | +| `getUserMedia({ audio })`, `getUserMedia({ video })` | `deviceAccess` `"microphone"`, `"camera"`; either one when the constraints are not an object literal | error when the package's minimum is 3.1.6 or later; warning otherwise | +| `new Notification()`, `Notification.requestPermission()` | `deviceAccess: ["notifications"]` | error when the package's minimum is 3.1.6 or later; warning otherwise | + +The Agent rows are reported once per file, naming every missing value, and so are the browser +permission rows. The rows marked error never work without the grant, on any client. Browser +permissions worked without a grant before MiniMax Code 3.1.6, which denies every permission the +manifest does not declare. A package whose minimum is below 3.1.6 still installs on those earlier +clients, so the check only warns; declaring the `deviceAccess` value fixes the call and raises the +minimum to 3.1.6, or 3.1.9 for `"camera"` and `"notifications"`. 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 diff --git a/docs/runtime.md b/docs/runtime.md index e509631..bce2057 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -1,6 +1,6 @@ # Mini App Node runtime -Verified against MiniMax Code 3.1.6. +Verified against MiniMax Code 3.1.11. ## Loading @@ -29,9 +29,9 @@ 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 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` | Present when `miniapp.json` declares `agentAccess` (`"session.run"`, and from MiniMax Code 3.1.6 also `"session.read"`). See `docs/agent.md`. | +| `tool` | Present when `miniapp.json` declares `"toolAccess": ["call"]` (MiniMax Code 3.1.6 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.6, 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 @@ -52,6 +52,10 @@ Close everything the entry started: the HTTP server, timers, child processes, st open file or database handles. The Host stops only the entry process; it does not discover or terminate processes the entry spawned. +Finish within 2 seconds. After a stop the Host waits 2 s, then sends `SIGTERM`, then force-kills the +entry 2 s later; on Windows `SIGTERM` already ends the process. A forced end runs no callback, so +do not leave durable writes for `dispose()`. + ## Loopback authentication From MiniMax Code 3.1.2, the Host guards the assigned port. Before it loads your entry, it installs a @@ -86,9 +90,42 @@ Both belong to the Host. Never call `console.log`, `console.info`, `console.debu ## Lifecycle -The process starts on demand and may be stopped at any time. Only a small number of Mini Apps run at -once, so assume yours can be stopped and restarted between two page views: keep durable state in -`dataDir`, not in memory. +The process starts on demand. While no page of the Mini App is visible, the Host may stop the +process, and it may destroy a hidden page and load it again when the user comes back. Neither comes +with notice, and no callback is guaranteed to run. + +MiniMax Code 3.1.10 and earlier run at most three Mini Apps at once. From 3.1.11 the limit depends on +the device's memory, from 3 to 10, and the Host frees memory in two cases only: + +- **The user opens a Mini App while the limit is reached.** The Host stops the least recently used + Mini App that is not in use, keeps its tabs, and tells the user with a toast. Opens by the Agent + never do this; they fail with `BUSY` instead. +- **The system is short of memory.** The Host first destroys hidden pages, oldest first, and keeps + their tabs; selecting one loads the page again. Under critical pressure it then stops whole Mini + Apps. On Windows, moderate pressure only destroys pages that have been hidden for 10 minutes or + more. + +There is no idle timeout and no keep-alive grant. Page requests, WebSocket traffic, and timers in +your process do not count as use, so polling or heartbeats keep nothing alive. + +Reclaim never stops a Mini App that has a visible page, that the current chat turn mentions with +`@`, whose MCP tools the Agent is calling, or that started less than 60 seconds ago; the user can +still stop it, and an update or restart replaces its process. A Mini App whose Node entry has a Host +request in flight (`context.agent`, `context.tool`, `context.hostConnector`), or finished one less +than 10 minutes ago, is not stopped to make room for another, and under critical pressure is +stopped only after the others. That makes a stop less likely but never rules it out. An open +`agent.observe` counts only when it starts. + +Write the Mini App so that a stop or reload loses nothing: + +- Write durable state to `dataDir` as it changes. Write files atomically (a temporary file in the + same directory, then rename it over the target) or use a transactional store such as SQLite. +- In the page, save view state when `visibilitychange` reports `document.visibilityState` as + `hidden`, and restore it on load. Do not rely on `beforeunload` or `unload`. +- A popped-out window counts as hidden while it is minimized or hidden, including after + `window.update({ visible: false })`. The window stays, and the page reloads in it when it is shown + again. +- Background work, such as ongoing data collection, saves its progress and resumes after a restart. ## Client diff --git a/docs/security.md b/docs/security.md index c13402d..fb1d2e1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Mini App security rules -Verified against MiniMax Code 3.1.6. +Verified against MiniMax Code 3.1.11. ## Process boundary @@ -16,7 +16,9 @@ Verified against MiniMax Code 3.1.6. ## State -- Store durable state under `context.dataDir`. +- Store durable state under `context.dataDir`, and write it as it changes: while the Mini App is + not visible the Host may stop its process or reload its page without notice (see "Lifecycle" in + `docs/runtime.md`). - Browser storage may hold view preferences (filters, sort order, expanded rows), not domain data. ## Writing outside `dataDir` @@ -69,11 +71,30 @@ in every user conversation. tools the Mini App needs. - Treat tool results as untrusted input, like any network response. -## Microphone +## Microphone and camera -`deviceAccess: ["microphone"]` lets the page record the user. +`deviceAccess` `"microphone"` and `"camera"` let the page record the user. The Host shows no +indicator for either. -- 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. +- Say in the README `## Data & access` when the Mini App records audio or video, and where it 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 tracks as soon as the + page is done with them. + +## System notifications + +`deviceAccess: ["notifications"]` lets the page show notifications as MiniMax Code. They can stay in +the system notification center after the Mini App closes and can appear on the lock screen. + +- Say in the README `## Data & access` what the Mini App notifies about. +- Notify only about what the user asked to hear about. Keep secrets, and the content of the user's + files and conversations, out of the notification text. + +## Host navigation + +`hostAccess: ["navigation.read"]` tells the page which part of MiniMax Code the user is on (home, a +conversation, plugins, schedule, or settings), never which conversation. + +- Declare it only when the Mini App reacts to where the user is, and say what for in the README + `## Data & access`. +- Keep what you read on the user's machine, like conversations read with `"session.read"`. diff --git a/docs/tool.md b/docs/tool.md index 7cfecb4..9f01110 100644 --- a/docs/tool.md +++ b/docs/tool.md @@ -1,6 +1,6 @@ # Mini App tool API -Verified against MiniMax Code 3.1.6. Clients before 3.1.5 reject packages that declare +Verified against MiniMax Code 3.1.11. Clients before 3.1.6 reject packages that declare `toolAccess`. `context.tool` lets the Node entry call the tools installed on the user's MiniMax Code: every diff --git a/examples/hello-miniapp/miniapp/client/client-api.ts b/examples/hello-miniapp/miniapp/client/client-api.ts index 85b761d..a944e30 100644 --- a/examples/hello-miniapp/miniapp/client/client-api.ts +++ b/examples/hello-miniapp/miniapp/client/client-api.ts @@ -443,6 +443,8 @@ export interface MiniAppClientAPI { readonly window?: MiniAppWindowHandle; /** Absent on older Hosts; check `capabilities.has('speech.transcribe')`. */ readonly speech?: MiniAppSpeechAPI; + /** Effective Host preferences and bounded navigation; absent on older Hosts. */ + readonly host?: MiniAppHostAPI; /** * 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`. @@ -516,8 +518,12 @@ export interface MiniAppWindowOptions { } export type MiniAppWindowState = - | { readonly mode: 'inline' } - | { readonly mode: 'window'; readonly window: MiniAppWindowOptions }; + | { readonly mode: 'inline'; readonly modeChangeReloadsPage?: boolean } + | { + readonly mode: 'window'; + readonly window: MiniAppWindowOptions; + readonly modeChangeReloadsPage?: boolean; + }; /** Every field is optional; omitted fields are left unchanged. */ export type MiniAppWindowPatch = Partial<{ @@ -548,7 +554,10 @@ export interface MiniAppWindowHandle { /** * `update({ mode: ... })` or `focus()` require the call to originate from a * user gesture (`navigator.userActivation.isActive`); otherwise they reject - * with `not_permitted` before any Host round trip. + * with `not_permitted` before any Host round trip. The `click` handler of a + * `Notification` the page created counts as one. + * When getState().modeChangeReloadsPage is true, mode changes end this document. + * Do not rely on the old Promise continuation; read state on the destination page. */ update(patch: MiniAppWindowPatch): Promise; focus(): Promise; @@ -556,6 +565,70 @@ export interface MiniAppWindowHandle { onDidChangeState(listener: (state: MiniAppWindowState) => void): MiniAppWindowDisposable; } +/** Page-visible Host state. Resource identities and pending preferences never enter these values. */ +export interface MiniAppHostUIState { + readonly theme: 'light' | 'dark'; + readonly language: string; +} + +export type MiniAppHostView = 'home' | 'conversation' | 'plugins' | 'schedule' | 'other'; +export type MiniAppHostOverlay = { readonly type: 'settings' } | null; + +export interface MiniAppHostNavigationState { + /** Short-lived opaque value scoped to this page and its observation authorization. */ + readonly key: string; + readonly view: MiniAppHostView; + readonly overlay: MiniAppHostOverlay; +} + +export type MiniAppHostOpenTarget = + | { readonly id: 'home' } + | { readonly id: 'plugins' } + | { readonly id: 'schedule' } + | { readonly id: 'settings'; readonly section?: 'appearance' | 'language' } + | { readonly id: 'session'; readonly sessionId: string; readonly source: 'local' }; + +export type MiniAppHostOpenResult = + | { readonly status: 'opened' } + | { readonly status: 'cancelled'; readonly reason: 'user_cancelled' | 'superseded' }; + +export type MiniAppHostErrorCode = + | 'invalid_request' + | 'not_permitted' + | 'host_unavailable' + | 'unsupported_source' + | 'unsupported_target' + | 'target_unavailable' + | 'result_unknown'; +export interface MiniAppHostError { + readonly code: MiniAppHostErrorCode; + readonly message: string; +} +export interface MiniAppHostDisposable { + dispose(): void; +} +export interface MiniAppHostSubscribeOptions { + readonly onError?: (error: MiniAppHostError) => void; +} + +export interface MiniAppHostAPI { + readonly ui: { + getState(): Promise; + subscribe( + listener: (state: MiniAppHostUIState) => void, + options?: MiniAppHostSubscribeOptions, + ): MiniAppHostDisposable; + }; + readonly navigation: { + getState(): Promise; + subscribe( + listener: (state: MiniAppHostNavigationState) => void, + options?: MiniAppHostSubscribeOptions, + ): MiniAppHostDisposable; + open(target: MiniAppHostOpenTarget): Promise; + }; +} + export interface McodeClientAPI { readonly miniapp: MiniAppClientAPI; } diff --git a/plugins/webguhui/minimax-video/README.md b/plugins/webguhui/minimax-video/README.md index ba7c7c0..ecaef72 100644 --- a/plugins/webguhui/minimax-video/README.md +++ b/plugins/webguhui/minimax-video/README.md @@ -6,7 +6,7 @@ A local video editing workspace for MiniMax Code, adapted from [OpenChatCut](htt ## Install and use -Requires MiniMax Code **3.1.5 or later** (1.0.3 and earlier need 3.1.2). Copy this entire `minimax-video` directory, including `.minimax-plugin`, into `/plugins/minimax-video/`, then restart the client and ask it to open **MiniMax Video**. The plugin ID is `minimax-video`. This is a new installation identity; existing `openchatcut` projects are not automatically migrated. Keep the previous installation and its data until your projects have been transferred and verified. +Requires MiniMax Code **3.1.6 or later** (1.0.3 and earlier need 3.1.2). Copy this entire `minimax-video` directory, including `.minimax-plugin`, into `/plugins/minimax-video/`, then restart the client and ask it to open **MiniMax Video**. The plugin ID is `minimax-video`. This is a new installation identity; existing `openchatcut` projects are not automatically migrated. Keep the previous installation and its data until your projects have been transferred and verified. On first launch, choose China mainland or global download sources. Media dependencies are downloaded for the current platform and verified against pinned checksums. You do not need to install Node, FFmpeg, or npm yourself. Open **Settings & About** to change download sources later. Cached downloads are reused; missing resources need network access. @@ -20,11 +20,11 @@ For generated media, configure a suitable tool in MiniMax Code first. Ask for on ## Tested environment -- MiniMax Code **3.1.3-preview.4675** (Internal Code), macOS Apple Silicon, 2026-10-19. Minimum supported manifest version: MiniMax Code 3.1.5, because 1.0.4 declares `deviceAccess`. +- MiniMax Code **3.1.3-preview.4675** (Internal Code), macOS Apple Silicon, 2026-10-19. Minimum supported manifest version: MiniMax Code 3.1.6, because 1.0.4 declares `deviceAccess`. - Native host: sample creation/playback, project preservation, assistant history recovery, China/global source switching, MP3 export, and Agent image generation → import → draft frame inspection → preview → apply → MP4 export. - Export evidence: a 12-second non-silent MP3 and a 15-second 1280×720, 30 fps H.264/AAC MP4; complete decode and inspection of the generated ending frame passed. - Platform manifests include macOS arm64/x64, Windows x64 and Linux glibc x64/arm64. **Only macOS arm64 was tested.** Windows ARM64 and Linux musl are not supported. Platform selection uses the host process architecture. -- 1.0.4 only adds `deviceAccess: ["microphone"]`, so that voiceover recording keeps working on MiniMax Code 3.1.5 and later, which deny microphone requests a Mini App does not declare. Voiceover recording has not been tested inside MiniMax Code with this change; `npm run check` passes. +- 1.0.4 only adds `deviceAccess: ["microphone"]`, so that voiceover recording keeps working on MiniMax Code 3.1.6 and later, which deny microphone requests a Mini App does not declare. Voiceover recording has not been tested inside MiniMax Code with this change; `npm run check` passes. - Windows, every advanced effect/export format, all generation providers, and a completely offline first installation are unverified. - UI is primarily Chinese. Local assistant history is tied to browser storage/origin and can be lost when that storage is cleared or the origin changes. Use the full Agent conversation to check interrupted work; never regenerate paid media merely because a reply timed out. diff --git a/plugins/webguhui/minimax-video/README.zh-CN.md b/plugins/webguhui/minimax-video/README.zh-CN.md index 56e5d01..f0f95f9 100644 --- a/plugins/webguhui/minimax-video/README.zh-CN.md +++ b/plugins/webguhui/minimax-video/README.zh-CN.md @@ -6,7 +6,7 @@ ## 安装与上手 -需要 MiniMax Code 3.1.5 或更新版本(1.0.3 及更早版本需要 3.1.2)。把本目录连同隐藏的 `.minimax-plugin` 复制到 `/plugins/minimax-video/`,重启客户端后让 Agent 打开 MiniMax Video。插件 ID 为 `minimax-video`。宿主将其视为新插件,旧 `openchatcut` 工程不会自动迁移。请保留旧插件和数据,完成工程转移并确认可用后再处理旧安装。 +需要 MiniMax Code 3.1.6 或更新版本(1.0.3 及更早版本需要 3.1.2)。把本目录连同隐藏的 `.minimax-plugin` 复制到 `/plugins/minimax-video/`,重启客户端后让 Agent 打开 MiniMax Video。插件 ID 为 `minimax-video`。宿主将其视为新插件,旧 `openchatcut` 工程不会自动迁移。请保留旧插件和数据,完成工程转移并确认可用后再处理旧安装。 首次启动选择中国大陆或海外下载源,自动安装经过摘要校验的媒体依赖,无需手动安装 Node、npm 或 FFmpeg。后续可在“设置与关于”切换来源。已有缓存可复用,未下载的资源需要联网。 @@ -22,7 +22,7 @@ 2026-10-19 在 macOS Apple Silicon、MiniMax Code 3.1.3-preview.4675 原生窗口实测:示例创建和播放、工程保留、助手记录恢复、下载源切换、MP3 导出,以及 AI 真实生图、导入、抽帧、预览、应用、MP4 导出。导出的 12 秒 MP3 非静音;15 秒 720p/30fps MP4 完整解码,结尾可见生成图片。 -1.0.4 只新增了 `deviceAccess: ["microphone"]` 声明,让旁白录音在 MiniMax Code 3.1.5 及更高版本上继续可用(这些版本会拒绝未声明的麦克风请求);这次改动后的旁白录音尚未在 MiniMax Code 内实测,`npm run check` 已通过。 +1.0.4 只新增了 `deviceAccess: ["microphone"]` 声明,让旁白录音在 MiniMax Code 3.1.6 及更高版本上继续可用(这些版本会拒绝未声明的麦克风请求);这次改动后的旁白录音尚未在 MiniMax Code 内实测,`npm run check` 已通过。 Windows、全部高级特效和导出格式、每种生成服务未逐一验收。平台清单支持范围和数据访问说明见 [English README](README.md#data--access)。首次离线安装不可用;本地聊天显示记录可能因清缓存或访问地址变化而丢失,恢复时先检查完整对话,不自动重复生成。 diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs index 2ccbfe5..f24122f 100644 --- a/scripts/lib/contract.mjs +++ b/scripts/lib/contract.mjs @@ -1,24 +1,32 @@ // Constants shared by every rule. Values mirror the MiniMax Code package reader. -export const HOST_VERSION = '3.1.6'; +// The next MiniMax Code release, frozen from preview_train. Features that ship first in it use this +// constant, so a late renumbering is a one-line change. +const NEXT_RELEASE = '3.1.11'; -// 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 HOST_VERSION = NEXT_RELEASE; + +// A feature's minimum is the first version with released installers for both macOS and Windows. +// Clients older than it reject the whole package when it declares the feature. 3.1.5 shipped no +// installers and 3.1.8 shipped none for Windows, so neither is a minimum; preview builds and bare +// tags never count. 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', - // Released 3.1.4 still rejects "session.read"; only 3.1.4 preview builds accepted it. - 'agentAccess.session.read': '3.1.5', + 'agentAccess.session.read': '3.1.6', agentConcurrency: '3.1.2', - deviceAccess: '3.1.5', - engines: '3.1.5', + deviceAccess: '3.1.6', + 'deviceAccess.camera': '3.1.9', + 'deviceAccess.notifications': '3.1.9', + engines: '3.1.6', + hostAccess: NEXT_RELEASE, 'surface.window': '3.1.2', - toolAccess: '3.1.5', + toolAccess: '3.1.6', }); // 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'; +// not grant (microphone, camera, notifications included); earlier clients granted them silently. +export const BROWSER_PERMISSION_GATE_VERSION = '3.1.6'; export const MANIFEST_FIELDS = new Set([ '$schema', @@ -50,6 +58,7 @@ export const SEMVER = export const ICON_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.(?:png|jpe?g|webp)$/u; export const APP_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.app\.json$/u; +export const APP_FIELDS = new Set(['$schema', 'schemaVersion', 'provider']); export const MCP_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.mcp\.json$/u; export const SKILL_PATH = /^skills\/[A-Za-z0-9._-]+\/SKILL\.md$/u; export const HOOK_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.json$/u; @@ -86,6 +95,7 @@ export const MINIAPP_FIELDS = new Set([ 'engines', 'toolAccess', 'deviceAccess', + 'hostAccess', ]); export const ARTIFACT_FIELDS = new Set(['client', 'node']); export const RUNTIME_FIELDS = new Set(['kind', 'entry', 'lifecycle']); @@ -97,8 +107,9 @@ 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']); +// Items added after the first release of deviceAccess get their own FEATURE_MIN_HOST_VERSION key. +export const DEVICE_ACCESS_ITEMS = new Set(['microphone', 'camera', 'notifications']); +export const HOST_ACCESS_CAPABILITIES = new Set(['navigation.read']); 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. diff --git a/scripts/lib/rules/manifest.mjs b/scripts/lib/rules/manifest.mjs index 176982f..d8a2aea 100644 --- a/scripts/lib/rules/manifest.mjs +++ b/scripts/lib/rules/manifest.mjs @@ -3,7 +3,9 @@ import path from 'node:path'; import { AGENT_PATH, APP_PATH, + APP_FIELDS, CATEGORIES, + CONNECTOR_PROVIDER, HOOK_PATH, HOST_BINDING_PATH, ICON_PATH, @@ -16,7 +18,7 @@ import { SKILL_PATH, VERSION_MAX_LENGTH, } from '../contract.mjs'; -import { isRecord, pathExists } from '../fs.mjs'; +import { isRecord, pathExists, readJsonFile } from '../fs.mjs'; const FILE = '.minimax-plugin/plugin.json'; @@ -60,10 +62,8 @@ export async function checkManifest(report, { packageDir, manifest }) { } } - const apps = await references(report, packageDir, manifest.apps, 'apps', APP_PATH, { required: true, mustExist: false }); - if (apps && apps.length > 0) { - report.warning('MANIFEST_APPS_IGNORED', 'apps entries are ignored for locally installed packages', FILE); - } + const apps = await references(report, packageDir, manifest.apps, 'apps', APP_PATH, { required: true, mustExist: true }); + if (apps) await checkApps(report, packageDir, apps); await references(report, packageDir, manifest.mcpServers, 'mcpServers', MCP_PATH, { required: true, mustExist: true }); await references(report, packageDir, manifest.skills, 'skills', SKILL_PATH, { required: true, mustExist: true }); await references(report, packageDir, manifest.hooks, 'hooks', HOOK_PATH, { required: false, mustExist: true }); @@ -142,3 +142,34 @@ async function references(report, packageDir, value, label, pattern, { required, } return items; } + +// Same rule set as the Host's App reference reader, which local packages use from 3.1.9. +async function checkApps(report, packageDir, apps) { + const providers = new Set(); + // references() already reported bad patterns, missing files, and repeated paths. + for (const item of new Set(apps)) { + const absolutePath = path.join(packageDir, ...item.split('/')); + if (!APP_PATH.test(item) || !(await pathExists(absolutePath, 'file'))) continue; + const file = await readJsonFile(absolutePath); + if (!file.ok) { + report.error('MANIFEST_APP_INVALID', `${item} ${file.detail}`, item); + continue; + } + const value = file.value; + if ( + !isRecord(value) || + Object.keys(value).some((key) => !APP_FIELDS.has(key)) || + (value.$schema !== undefined && typeof value.$schema !== 'string') || + value.schemaVersion !== 1 || + typeof value.provider !== 'string' || + !CONNECTOR_PROVIDER.test(value.provider) + ) { + report.error('MANIFEST_APP_INVALID', `${item} must be { "schemaVersion": 1, "provider": "" }`, item); + continue; + } + if (providers.has(value.provider)) { + report.error('MANIFEST_APP_INVALID', `${item} repeats provider "${value.provider}"; the Host keeps only the first`, item); + } + providers.add(value.provider); + } +} diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index c181c57..1d6bd5b 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -10,6 +10,7 @@ import { ENGINES_REQUIREMENT, ENTRY_EXTENSION, EXCLUDED_DIRECTORY, + HOST_ACCESS_CAPABILITIES, HOST_CONNECTOR_ACCESS_FIELDS, MCP_ENDPOINT_FIELDS, MCP_SERVER_NAME, @@ -31,7 +32,7 @@ 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 } }; + return { nodeRoots: [], clientRoots: [], features: [], access: { agent: [], tool: false, device: [], host: [] } }; } export async function checkMiniApp(report, { packageDir, manifest }) { @@ -57,17 +58,21 @@ export async function checkMiniApp(report, { packageDir, manifest }) { 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 host = checkAccessList(report, manifest.hostAccess, 'hostAccess', HOST_ACCESS_CAPABILITIES, 'MINIAPP_HOST_ACCESS_INVALID'); const engines = checkEngines(report, manifest.engines); if (engines) resolved.engines = engines; - resolved.access = { agent, tool: tool.includes('call'), microphone: device.includes('microphone') }; + resolved.access = { agent, tool: tool.includes('call'), device, host }; 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']) { + for (const field of ['engines', 'toolAccess', 'deviceAccess', 'hostAccess']) { if (manifest[field] !== undefined) resolved.features.push(field); } + for (const item of ['camera', 'notifications']) { + if (device.includes(item)) resolved.features.push(`deviceAccess.${item}`); + } return resolved; } diff --git a/scripts/lib/rules/readme.mjs b/scripts/lib/rules/readme.mjs index 469266d..d2fc53b 100644 --- a/scripts/lib/rules/readme.mjs +++ b/scripts/lib/rules/readme.mjs @@ -5,7 +5,9 @@ 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; +// A pre-release suffix (`3.1.11-preview.4900`) ranks below its release, as in SemVer. +// The suffix starts with a letter so that a range such as `3.1.6-3.1.11` is not read as one. +const HOST_VERSION_MENTION = /MiniMax Code\D{0,16}?(\d+)\.(\d+)\.(\d+)(-[A-Za-z][0-9A-Za-z.-]*)?/gu; export async function checkReadme(report, { packageDir, features = [], engines }) { const text = await readText(path.join(packageDir, 'README.md')); @@ -24,7 +26,10 @@ 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); + const satisfied = [...tested.matchAll(HOST_VERSION_MENTION)].some((match) => { + const order = compareVersions(match.slice(1, 4).join('.'), required); + return order > 0 || (order === 0 && match[4] === undefined); + }); if (!satisfied) { report.warning( 'README_MIN_VERSION_MISSING', diff --git a/scripts/lib/rules/usage.mjs b/scripts/lib/rules/usage.mjs index 7213493..dabf951 100644 --- a/scripts/lib/rules/usage.mjs +++ b/scripts/lib/rules/usage.mjs @@ -21,12 +21,22 @@ const API_RULES = [ }, { pattern: /\bspeech\??\.transcribe\s*\(/u, - granted: (access) => access.microphone, + granted: (access) => access.device.includes('microphone'), message: 'calls speech.transcribe but miniapp.json does not declare deviceAccess ["microphone"]; every call rejects with not_permitted', }, + { + // Any identifier ending in host/Host, such as `miniAppHost`; a bare `navigation` is a router's. + pattern: /\b\w*[Hh]ost\??\.navigation\??\.(?:getState|subscribe)\s*(?:\?\.)?\s*\(/u, + granted: (access) => access.host.includes('navigation.read'), + message: 'reads Host navigation but miniapp.json does not declare hostAccess ["navigation.read"]; the calls reject with not_permitted', + }, ]; -const GET_USER_MEDIA = /\bgetUserMedia\s*\(/u; +const GET_USER_MEDIA = /\bgetUserMedia\s*\(/gu; +const NOTIFICATION = /\bnew\s+(?:window\.)?Notification\s*\(|\bNotification\s*\.\s*requestPermission\s*\(/u; +const DEVICE_ORDER = ['microphone', 'camera', 'notifications']; +const FALSE_LIKE = new Set(['false', '!1', '0', 'null', 'undefined']); +const EITHER_MEDIA_DEVICE = '"microphone" or "camera"'; export async function checkAccessUsage(report, { packageDir, roots, access, minimum }) { const gated = minimum !== undefined && compareVersions(minimum, BROWSER_PERMISSION_GATE_VERSION) >= 0; @@ -37,10 +47,90 @@ export async function checkAccessUsage(report, { packageDir, roots, access, mini 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); + checkBrowserPermissions(report, source, access.device, relativePath, gated); + } +} + +// The devices each getUserMedia call asks for: its literal constraints when it has them, or +// undefined when the request cannot be read (not an object literal, or one naming neither key). +function requestedMediaDevices(source) { + const requests = []; + for (const match of source.matchAll(GET_USER_MEDIA)) { + const literal = objectLiteralAt(source, match.index + match[0].length); + const audio = literal === undefined ? undefined : keyRequest(literal, 'audio'); + const video = literal === undefined ? undefined : keyRequest(literal, 'video'); + if (audio === undefined && video === undefined) requests.push(undefined); + else requests.push([audio && 'microphone', video && 'camera'].filter(Boolean)); + } + return requests; +} + +// The `{ ... }` starting at `start`, skipping braces inside string and template literals. +function objectLiteralAt(source, start) { + let index = start; + while (/\s/u.test(source[index] ?? '')) index += 1; + if (source[index] !== '{') return undefined; + let depth = 0; + for (let end = index; end < source.length; end += 1) { + const char = source[end]; + if (char === '"' || char === "'" || char === '`') { + end = stringEnd(source, end); + if (end === -1) return undefined; + } else if (char === '{') depth += 1; + else if (char === '}' && (depth -= 1) === 0) return source.slice(index, end + 1); + } + return undefined; +} + +function stringEnd(source, start) { + for (let index = start + 1; index < source.length; index += 1) { + if (source[index] === '\\') index += 1; + else if (source[index] === source[start]) return index; + } + return -1; +} + +// `{ audio: true }`, `{ "audio": !0 }`, and shorthand `{ audio }` ask for the device; false-like +// values do not. Undefined when the literal does not name the key. +function keyRequest(literal, key) { + const value = new RegExp(String.raw`(?:^|[{,\s])["']?${key}["']?\s*:\s*([^,}\s]+)`, 'u').exec(literal); + if (value) return !FALSE_LIKE.has(value[1]); + return new RegExp(String.raw`[{,]\s*${key}\s*[,}]`, 'u').test(literal) ? true : undefined; +} + +// One diagnostic per file for every device its browser-permission calls need and lack. Clients +// before the gate granted these silently, so the calls fail on every client that installs the +// package only when the package already needs a gated client. +function checkBrowserPermissions(report, source, granted, relativePath, gated) { + const needed = new Set(); + let unknownMedia = false; + for (const request of requestedMediaDevices(source)) { + if (request === undefined) unknownMedia = true; + else for (const device of request) needed.add(device); + } + if (NOTIFICATION.test(source)) needed.add('notifications'); + const missing = DEVICE_ORDER.filter((device) => needed.has(device) && !granted.includes(device)).map((device) => `"${device}"`); + if (unknownMedia && !granted.includes('microphone') && !granted.includes('camera') && !needed.has('microphone') && !needed.has('camera')) { + missing.push(EITHER_MEDIA_DEVICE); + } + if (missing.length === 0) return; + const base = `uses browser permissions that need deviceAccess ${listOf(missing)}; MiniMax Code ${BROWSER_PERMISSION_GATE_VERSION} and later deny them`; + if (gated) { + report.error('ACCESS_UNDECLARED', `${base}, and this package needs ${BROWSER_PERMISSION_GATE_VERSION} or later`, relativePath); + } else { + report.warning( + 'ACCESS_UNDECLARED', + `${base}; they work only on clients before ${BROWSER_PERMISSION_GATE_VERSION}. Declaring deviceAccess raises the package's minimum client version (docs/package-contract.md)`, + relativePath, + ); } } +function listOf(items) { + if (items.length <= 2) return items.join(' and '); + return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`; +} + // One diagnostic per file, naming every agentAccess value the file's calls need and lack. function checkAgentUsage(report, source, granted, relativePath) { const missing = []; @@ -57,18 +147,3 @@ function checkAgentUsage(report, source, granted, relativePath) { 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) { - 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/test/manifest.test.mjs b/test/manifest.test.mjs index 38addaa..1afa370 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -52,12 +52,33 @@ test('manifest: empty exampleQueries is a warning; blank entry is an error', asy assert.deepEqual(codes(blank.report.diagnostics), ['MANIFEST_FIELD_INVALID']); }); -test('manifest: apps entries must match *.app.json; non-empty apps only warns', async () => { - const bad = await run((m) => ({ ...m, apps: ['foo'] })); - assert.deepEqual(codes(bad.report.diagnostics, 'error'), ['MANIFEST_REFERENCE_INVALID']); - const ok = await run((m) => ({ ...m, apps: ['x.app.json'] })); - assert.deepEqual(codes(ok.report.diagnostics), ['MANIFEST_APPS_IGNORED']); - assert.equal(ok.report.diagnostics[0].level, 'warning'); +test('manifest: apps entries are *.app.json files that exist and hold one Connector provider each', async () => { + const writeApp = (name, value) => async (dir) => writeFile(path.join(dir, name), `${JSON.stringify(value)}\n`); + const ok = await run((m) => ({ ...m, apps: ['notion.app.json'] }), writeApp('notion.app.json', { schemaVersion: 1, provider: 'notion' })); + assert.deepEqual(ok.report.diagnostics, []); + const badPattern = await run((m) => ({ ...m, apps: ['foo'] })); + assert.deepEqual(codes(badPattern.report.diagnostics), ['MANIFEST_REFERENCE_INVALID']); + const missing = await run((m) => ({ ...m, apps: ['notion.app.json'] })); + assert.deepEqual(codes(missing.report.diagnostics), ['MANIFEST_REFERENCE_MISSING']); + const directory = await run((m) => ({ ...m, apps: ['notion.app.json'] }), async (dir) => mkdir(path.join(dir, 'notion.app.json'))); + assert.deepEqual(codes(directory.report.diagnostics), ['MANIFEST_REFERENCE_MISSING']); + const notJson = await run((m) => ({ ...m, apps: ['notion.app.json'] }), async (dir) => writeFile(path.join(dir, 'notion.app.json'), '{ provider: notion }')); + assert.deepEqual(codes(notJson.report.diagnostics), ['MANIFEST_APP_INVALID']); + const repeatedPath = await run((m) => ({ ...m, apps: ['notion.app.json', 'notion.app.json'] }), writeApp('notion.app.json', { schemaVersion: 1, provider: 'notion' })); + assert.equal(repeatedPath.report.diagnostics.length, 1, JSON.stringify(repeatedPath.report.diagnostics)); + for (const value of [{ schemaVersion: 2, provider: 'notion' }, { schemaVersion: 1, provider: 'Notion' }, { schemaVersion: 1, provider: 'notion', name: 'x' }, { schemaVersion: 1 }]) { + const bad = await run((m) => ({ ...m, apps: ['notion.app.json'] }), writeApp('notion.app.json', value)); + assert.deepEqual(codes(bad.report.diagnostics), ['MANIFEST_APP_INVALID'], JSON.stringify(value)); + } + const duplicate = await run( + (m) => ({ ...m, apps: ['a.app.json', 'b.app.json'] }), + async (dir) => { + await writeApp('a.app.json', { schemaVersion: 1, provider: 'notion' })(dir); + await writeApp('b.app.json', { $schema: 'https://example.com/app.json', schemaVersion: 1, provider: 'notion' })(dir); + }, + ); + assert.deepEqual(codes(duplicate.report.diagnostics), ['MANIFEST_APP_INVALID']); + assert.match(duplicate.report.diagnostics[0].message, /provider "notion"/u); }); test('manifest: skills, mcpServers, hooks, hostBindings need matching pattern and existing file', async () => { diff --git a/test/miniapp.test.mjs b/test/miniapp.test.mjs index 1cadd8c..be2c653 100644 --- a/test/miniapp.test.mjs +++ b/test/miniapp.test.mjs @@ -26,7 +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 }, + access: { agent: [], tool: false, device: [], host: [] }, }); }); @@ -210,17 +210,38 @@ test('miniapp: toolAccess allows only "call", reports the feature, and grants to } }); -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); +test('miniapp: deviceAccess allows microphone, camera, and notifications and reports each feature', async () => { + const mic = await run((m) => { m.deviceAccess = ['microphone']; return m; }); + assert.deepEqual(mic.report.diagnostics, []); + assert.deepEqual(mic.resolved.features, ['deviceAccess']); + assert.deepEqual(mic.resolved.access.device, ['microphone']); + const all = await run((m) => { m.deviceAccess = ['notifications', 'camera', 'microphone', 'camera']; return m; }); + assert.deepEqual(all.report.diagnostics, []); + assert.deepEqual(all.resolved.features, ['deviceAccess', 'deviceAccess.camera', 'deviceAccess.notifications']); + assert.deepEqual(all.resolved.access.device, ['camera', 'microphone', 'notifications']); 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']) { + assert.deepEqual(empty.resolved.access.device, []); + for (const value of [['geolocation'], ['Camera'], ['camera', 'clipboard-read'], 'microphone']) { const bad = await run((m) => { m.deviceAccess = value; return m; }); assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_DEVICE_ACCESS_INVALID'], JSON.stringify(value)); + assert.deepEqual(bad.resolved.access.device, []); + } +}); + +test('miniapp: hostAccess allows only "navigation.read", reports the feature, and grants navigation reads', async () => { + const granted = await run((m) => { m.hostAccess = ['navigation.read']; return m; }); + assert.deepEqual(granted.report.diagnostics, []); + assert.deepEqual(granted.resolved.features, ['hostAccess']); + assert.deepEqual(granted.resolved.access.host, ['navigation.read']); + const empty = await run((m) => { m.hostAccess = []; return m; }); + assert.deepEqual(empty.report.diagnostics, []); + assert.deepEqual(empty.resolved.features, ['hostAccess']); + assert.deepEqual(empty.resolved.access.host, []); + for (const value of [['navigation.open'], ['ui.read'], 'navigation.read', [null]]) { + const bad = await run((m) => { m.hostAccess = value; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_HOST_ACCESS_INVALID'], JSON.stringify(value)); + assert.deepEqual(bad.resolved.access.host, []); } }); diff --git a/test/package.test.mjs b/test/package.test.mjs index e1c0cbf..e366851 100644 --- a/test/package.test.mjs +++ b/test/package.test.mjs @@ -67,7 +67,7 @@ test('package: features from plugin.json and miniapp.json reach the README minim assert.deepEqual(await validatePackage(dir), []); }); -test('package: getUserMedia without the microphone turns from warning to error once the package needs 3.1.5', async () => { +test('package: getUserMedia without the microphone turns from warning to error once the package needs 3.1.6', 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'); @@ -82,7 +82,7 @@ test('package: getUserMedia without the microphone turns from warning to error o 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')); + await writeFile(readme, (await readFile(readme, 'utf8')).replace('MiniMax Code 3.0.73', 'MiniMax Code 3.1.6')); assert.deepEqual(await validatePackage(dir), []); }); diff --git a/test/readme.test.mjs b/test/readme.test.mjs index ce3f6f4..d43614f 100644 --- a/test/readme.test.mjs +++ b/test/readme.test.mjs @@ -63,13 +63,25 @@ test('readme: only a MiniMax Code version inside Tested environment satisfies th 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); +const testedOn = (line) => (text) => text.replace(TESTED_ON_3_0_73, line); + +test('readme: each feature names the first release with installers for both platforms', async () => { + const cases = [ + [['agentAccess.session.read', 'toolAccess', 'deviceAccess', 'engines'], '3.1.6', ['3.1.4-preview.4773', '3.1.5']], + [['deviceAccess.camera', 'deviceAccess.notifications'], '3.1.9', ['3.1.8']], + [['hostAccess'], '3.1.11', ['3.1.10', '3.1.11-preview.4900']], + ]; + for (const [features, minimum, tooOld] of cases) { + for (const feature of features) { + for (const version of tooOld) { + const stale = await run(testedOn(`- MiniMax Code ${version} on macOS.`), { features: [feature] }); + assert.deepEqual(codes(stale), ['README_MIN_VERSION_MISSING'], `${feature} on ${version}`); + assert.match(stale[0].message, new RegExp(`MiniMax Code ${minimum.replaceAll('.', '\\.')} or later`, 'u')); + } + assert.deepEqual(await run(testedOn(`- MiniMax Code ${minimum} on macOS.`), { features: [feature] }), [], feature); + } } + assert.deepEqual(await run(testedOn('- MiniMax Code 3.1.6-3.1.11 on macOS.'), { features: ['toolAccess'] }), []); }); test('readme: an engines bound above the feature minimum raises the version the README must name', async () => { diff --git a/test/usage.test.mjs b/test/usage.test.mjs index e60f481..c848cd6 100644 --- a/test/usage.test.mjs +++ b/test/usage.test.mjs @@ -6,7 +6,7 @@ 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 NONE = { agent: [], tool: false, device: [], host: [] }; const ROOTS = ['miniapp/client', 'miniapp/node']; async function run(files, { access = NONE, minimum } = {}) { @@ -94,22 +94,85 @@ test('usage: speech.transcribe without the microphone is an error on every versi 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 } }), []); + assert.deepEqual(await run({ 'miniapp/client/index.html': '\n' }, { access: { ...NONE, device: ['microphone'] } }), []); }); -test('usage: getUserMedia without the microphone is an error only when every client that installs the package denies it', async () => { +test('usage: getUserMedia without its device 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.match(anyVersion[0].message, /"microphone"/u); + assert.match(anyVersion[0].message, /before 3\.1\.6/u); + assert.equal((await run(files, { minimum: '3.1.5' }))[0].level, 'warning'); + for (const minimum of ['3.1.6', '3.2.0']) { + assert.deepEqual(codes(await run(files, { minimum }), 'error'), ['ACCESS_UNDECLARED'], minimum); + } + assert.deepEqual(await run(files, { access: { ...NONE, device: ['microphone'] }, minimum: '3.1.6' }), []); +}); + +test('usage: getUserMedia needs the devices it asks for', async () => { + const cases = [ + ['getUserMedia({ video: true })', ['microphone'], /"camera"/u], + ['getUserMedia({audio:!0,video:{width:640}})', ['microphone'], /"camera"/u], + ['getUserMedia({audio:!0,video:{width:640}})', ['camera'], /"microphone"/u], + ['getUserMedia(constraints)', [], /"microphone" or "camera"/u], + ['getUserMedia({ "audio": true })', [], /"microphone"/u], + ["getUserMedia({ 'video': true })", ['microphone'], /"camera"/u], + ["getUserMedia({ video: { facingMode: '}' }, audio: true })", ['camera'], /"microphone"/u], + ['getUserMedia({ ...base })', [], /"microphone" or "camera"/u], + ]; + for (const [call, device, missing] of cases) { + const diagnostics = await run({ 'miniapp/client/app.js': `${call};\n` }, { access: { ...NONE, device } }); + assert.deepEqual(codes(diagnostics), ['ACCESS_UNDECLARED'], call); + assert.match(diagnostics[0].message, missing, call); + } + for (const [call, device] of [ + ['getUserMedia({ video: true })', ['camera']], + ['getUserMedia({ audio: true, video: false })', ['microphone']], + ['getUserMedia({audio:!1,video:!0})', ['camera']], + ['getUserMedia(constraints)', ['camera']], + ['getUserMedia(constraints)', ['microphone']], + ]) { + assert.deepEqual(await run({ 'miniapp/client/app.js': `${call};\n` }, { access: { ...NONE, device } }), [], call); + } +}); + +test('usage: system notifications need deviceAccess "notifications"', async () => { + for (const source of ["new Notification('Done', { body });\n", 'await Notification.requestPermission();\n']) { + const anyVersion = await run({ 'miniapp/client/app.js': source }); + assert.deepEqual(codes(anyVersion), ['ACCESS_UNDECLARED'], source); + assert.equal(anyVersion[0].level, 'warning'); + assert.match(anyVersion[0].message, /"notifications"/u); + assert.deepEqual(codes(await run({ 'miniapp/client/app.js': source }, { minimum: '3.1.9' }), 'error'), ['ACCESS_UNDECLARED'], source); + assert.deepEqual(await run({ 'miniapp/client/app.js': source }, { access: { ...NONE, device: ['notifications'] } }), [], source); + } + assert.deepEqual(await run({ 'miniapp/client/app.js': 'const n = new NotificationCenter(); notify.showNotification(x);\n' }), []); +}); + +test('usage: one browser-permission diagnostic per file names every missing device', async () => { + const diagnostics = await run({ + 'miniapp/client/app.js': "getUserMedia({ audio: true });\ngetUserMedia({ video: true });\nnew Notification('x');\n", + }); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /"microphone", "camera", and "notifications"/u); +}); + +test('usage: reading Host navigation needs hostAccess; Host UI state and opening entries need nothing', async () => { + for (const source of [ + 'await window.mcode?.miniapp?.host?.navigation.getState();\n', + 'host.navigation.subscribe(render, { onError });\n', + 'miniAppHost.navigation.subscribe(render);\n', + 'host?.navigation?.getState?.();\n', + ]) { + const diagnostics = await run({ 'miniapp/client/app.js': source }); + assert.deepEqual(codes(diagnostics, 'error'), ['ACCESS_UNDECLARED'], source); + assert.match(diagnostics[0].message, /hostAccess/u); + assert.deepEqual(await run({ 'miniapp/client/app.js': source }, { access: { ...NONE, host: ['navigation.read'] } }), [], source); + } + for (const source of ["host.navigation.open({ id: 'home' });\n", 'host.ui.subscribe(apply);\nawait host.ui.getState();\n', 'const state = navigation.getState();\n']) { + assert.deepEqual(await run({ 'miniapp/client/app.js': source }), [], source); } - 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 () => {