diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 1e34a3644e..9d67e3dde4 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -204,6 +204,8 @@ jobs: - name: Verify native Keychain across processes run: cargo run --locked --manifest-path src-tauri/Cargo.toml --example credential_probe - name: Build unsigned macOS app and DMG + id: macos_build + shell: bash run: | mkdir -p .artifacts set -euo pipefail @@ -221,15 +223,32 @@ jobs: echo "::warning::DMG creation found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." sleep "$((attempt * 5))" done - - name: Upload failed macOS build diagnostics - if: failure() + - name: Collect macOS packaging diagnostics + if: ${{ always() && steps.macos_build.outcome == 'failure' }} + timeout-minutes: 2 + shell: bash + run: | + mkdir -p .artifacts + { + sw_vers || true + uname -m || true + df -h . "$RUNNER_TEMP" || true + hdiutil info || true + diskutil list || true + } > .artifacts/macos-packaging-diagnostics.log 2>&1 + - name: Upload macOS packaging diagnostics + if: ${{ always() && steps.macos_build.outcome != 'skipped' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: - name: desktop-macos-${{ matrix.runner }}-build-diagnostics + name: desktop-macos-${{ matrix.runner }}-packaging-diagnostics + include-hidden-files: true path: | desktop/.artifacts/macos-build*.log + desktop/.artifacts/macos-packaging-diagnostics.log desktop/src-tauri/target/release/bundle/dmg/bundle_dmg.sh + desktop/src-tauri/target/release/bundle/share/create-dmg/support/ if-no-files-found: warn + retention-days: 7 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: desktop-macos-${{ matrix.runner }}-internal-unsigned diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 80607cd91f..2e01617ce3 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -769,6 +769,7 @@ jobs: tests/builtin/runtime/test_external_skills.py tests/test_cli.py::test_remote_enroll_can_install_automatic_service_in_one_command tests/test_openclaw_cli.py::test_build_openclaw_plugin_runs_pnpm_install_non_interactively + tests/test_cli_minimax.py tests/test_opencode_cli.py tests/test_service.py tests/test_service_bootstrap.py diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index be554956d0..10301943e7 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -118,7 +118,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-112db11e-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-112db11e-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence, including `sqlite-atomic-bae5d714-v1`, `sqlite-source-artifact-87b6fae2-v1` and the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records, including the separate `sqlite-atomic-1da99747-linux-v1` qualification, also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -803,6 +803,34 @@ Repository hooks passed except the existing Windows Python type errors in unchan see ignored `.artifacts/desktop-shell/current-contract-prek.log`. 仓库全量钩子仍有未修改代码的既存 Windows Python 类型错误,其余钩子通过。 +#### Server 1da99747 Linux protocol qualification + +`sqlite-atomic-1da99747-linux-v1` records a separate qualification wheel for the same Server +contract as `sqlite-atomic-1da99747-v1`. It identifies Server sources at +`1da997476651c2850ff7141e2b7cd512d0152a90` and normalized OpenAPI digest +`5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c`. +The qualification wheel is `powercontext-1.4.1.dev139+g1da997476-py3-none-any.whl`, +SHA-256 `92c71e27f406df8663b6cac894ecef9fe8aff17454a5a0dda736b09624c305d9`. +All 540 packaged runtime files match the recorded Server commit byte for byte; the OpenAPI +contract, project configuration and lock also match. The sorted runtime source-manifest digest +is `da5b32b18298795299135c585c618d8238683eb596b2121f4904f61da23c3786` +(one `wheel/path sha256` entry per line, with a final newline). + +The existing real-Server harness passed against this isolated wheel and SQLite in all four modes: +anonymous loopback, static Bearer, HTTPS with an explicit CA and base path, and an injected +provider with enforced access. The ten recorded operations cover Scope selection and pagination, +Atomic Memory save/search/exact revision reads, identity changes, same-identity revocation, +committed writes with lost responses without replay, and independent Server survival after the +client exits. + +Qualification ran on Linux x86_64 with Python 3.12.3 and Rust 1.95.0. The standalone Rust client +compiled the unmodified product connection, transport, credential and error modules, plus +`server_probe.rs`, from Desktop merge commit `5bab8fc3720087e537a1a5cd1da613fd044780b7`. +Every client dependency version and checksum matches the product Cargo lock. The Tauri GUI shell +was excluded. This establishes Server protocol compatibility for the recorded sources and wheel; +it does not qualify Linux Desktop support, native macOS/Windows packaging, credential stores, +or installed UI workflows. Historical records retain their original identities and evidence. + #### Server c535d816 protocol qualification `sqlite-atomic-c535d816-v1` records Server sources at @@ -1017,7 +1045,6 @@ Headless Edge 155.0.4283.62 with synthetic IPC checked all five pages in 24 comb 无头 Edge 155.0.4283.62 使用合成 IPC 检查中英文、三种主题及上述四组 CSS 视口,共 24 种组合,五个页面没有横向溢出。在桌面宽度下,有无观察入口时“设置与诊断”的坐标一致;小窗口中它仍位于主导航下方。400×300 配合设备缩放因子 2 仅检查 800×600 在 200% 下的等效布局,不代表安装版 WebView 缩放。真实 Server、安装包、原生 IME、屏幕阅读器和 macOS 仍需实际验收。 - ## Shared feedback and global context (#2102) On 2026-10-22, Windows x64 local checks used Node 24.14.1, pnpm 11.13.1 and Rust 1.95.0. Focused feedback/context tests and all 50 frontend tests passed, together with typecheck, lint (including generated contracts and icons in check mode) and production build. Coverage includes pending/failed/disconnected initialization, local-state recovery without readiness probes, same-name Scope IDs, latest matching connection facts, identity/health/compatibility separation, prerequisite recovery, Scope pagination recovery, failed exact-read retries retaining the original revision, preserved drafts, local diagnostics, keyboard menus and modal focus. Existing save, unknown-write and late-response protections remain covered. diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 83488c296e..7e33a69ce5 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -171,6 +171,25 @@ ], "evidence": "desktop/VALIDATION.md#server-1da99747-protocol-qualification" }, + { + "id": "sqlite-atomic-1da99747-linux-v1", + "serverCommit": "1da997476651c2850ff7141e2b7cd512d0152a90", + "contractSha256": "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c", + "artifactSha256": "92c71e27f406df8663b6cac894ecef9fe8aff17454a5a0dda736b09624c305d9", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-1da99747-linux-protocol-qualification" + }, { "id": "sqlite-atomic-8f10c560-v1", "serverCommit": "8f10c560776b32b717c3511fad9da349982a5748", diff --git a/docs/en/docs/integrations/capabilities.md b/docs/en/docs/integrations/capabilities.md index cacd64b531..8945acc305 100644 --- a/docs/en/docs/integrations/capabilities.md +++ b/docs/en/docs/integrations/capabilities.md @@ -48,3 +48,4 @@ candidate_review permits listing and reading candidates only; it never grants de | bub | Evaluation harness | Master only | — | — | — | memory_read
memory_write
source_capture
context_injection
flush_or_checkpoint | | dify | Framework adapter | Experimental | — | — | — | memory_read
memory_write
source_capture
context_injection
handoff
experience_read_or_generate
skill_read_or_generate
candidate_review | | kimi-code | Agent host | Experimental | minimal, recommended, full | unsupported | — | memory_read
memory_write
source_capture
context_injection
persistent_scope_binding
work_contract
handoff
acknowledge
task_outcome
experience_read_or_generate
skill_read_or_generate
candidate_review
external_skill | +| minimax | Agent host | Experimental | — | unsupported | — | acknowledge
artifact_publication
candidate_review
experience_read_or_generate
external_skill
handoff
memory_read
memory_write
persistent_scope_binding
scope_organization
skill_read_or_generate
source_capture
task_outcome
work_contract | diff --git a/docs/en/docs/integrations/meta.json b/docs/en/docs/integrations/meta.json index 37ee1149dd..ed9dca268c 100644 --- a/docs/en/docs/integrations/meta.json +++ b/docs/en/docs/integrations/meta.json @@ -12,6 +12,7 @@ "dsh", "kimi-code", "hermes", + "minimax", "openclaw", "opencode", "pi", diff --git a/docs/en/docs/integrations/minimax.md b/docs/en/docs/integrations/minimax.md new file mode 100644 index 0000000000..80eb04bcad --- /dev/null +++ b/docs/en/docs/integrations/minimax.md @@ -0,0 +1,100 @@ +--- +status: community +title: MiniMax Code +description: Install the native MiniMax plugin and distinguish package discovery from Server access. +--- + +# MiniMax Code + +`community` · experimental + +PowerContext installs a native MiniMax plugin containing the `powercontext-project-context` Skill and a streamable HTTP MCP connection. MiniMax owns the MCP session. The plugin does not start the Server or automatically capture conversations. + +## Prerequisites and versions + +Use Python 3.11+, the PowerContext CLI, and a running PowerContext Server. Install the CLI, Server, and plugin from the same repository revision. For a pinned release, use its matching repository tag with `--ref`; the plugin manifest version is independent of the Python package version. Remote refs follow the existing branch/tag convention. To install a specific commit, check it out locally and use `--source`. + +Native tests cover the official npm CLI `@minimax-ai/code` versions 0.6.9 and 0.7.2 on Linux x86_64 with Node.js 22.23.3. Plugin-only MCP tool injection is not reliably qualified in either version: the host can report a connected plugin without making its tools available to the model, including through `tool_search`. Upgrading to 0.7.2, or passing setup or doctor, does not resolve this limitation. Desktop discovery and other platforms require separate qualification. See the [acceptance record](https://github.com/oceanbase/powercontext/blob/master/integrations/minimax/acceptance.md) for commands, diagnostic artifacts, and the evidence boundary. + +The verified official 0.7.2 installation flow keeps the private `powercontext` MCP entry created by setup enabled together with the complete plugin. This provides MCP tools through the private connection and retains the plugin Skill. Removing or disabling the private entry in the official npm versions relies on the unqualified plugin-only path. Setup preserves an existing disabled preference; explicitly enable that private entry in `mcp.json` if you choose to use this verified path, then run `powercontext config agent minimax` and restart MiniMax. + +## Install and select a target + +```bash +powercontext setup minimax + +# Repository root or the native plugin directory +powercontext setup minimax --source /path/to/powercontext + +# A branch or tag containing the plugin, matched to your Server revision +powercontext setup minimax --source oceanbase/powercontext --ref + +# Explicit MiniMax data root and executable launcher +powercontext setup minimax --source /path/to/powercontext \ + --minimax-data-dir "/path/to/MiniMax data" \ + --minimax-command /path/to/mcode +``` + +The destination is `/plugins/powercontext`, including hidden files, Skill references, icon, and license. `--minimax-data-dir` takes precedence over nonempty `MINIMAX_DATA_DIR`, then `MAVIS_DATA_DIR`, then the saved installation target. Without these choices, the installer asks the selected CLI's `plugin marketplace list --json` for its local directory, including native profile/build selection. If the CLI is missing, the default is `~/.minimax`; a selected `MAVIS_PROFILE` requires an explicit data root in that case. Older or custom builds whose marketplace response cannot be interpreted require `--minimax-data-dir`. + +The launcher comes from `--minimax-command`, the saved launcher for the same data root, or `mcode` on PATH. An invalid explicit or saved launcher fails before installation. Launch MiniMax with the selected data directory. Native discovery checks run against a disposable snapshot of that installation, with its local plugin enablement state. Setup saves the target for later `doctor minimax` and `config agent minimax` commands. Environment directory overrides still take precedence over that saved target. + +Interactive `powercontext setup` includes MiniMax. Multiple agents share the same setup behavior: + +```bash +powercontext setup codex minimax --source /path/to/powercontext --json +powercontext doctor minimax --json +``` + +## Run the plugin-only source fix + +The [MiniMax readiness fix](https://github.com/hidb4ai/minimax-code/commit/0ea69bd04c90cc151cae2eff9d5b7b4465d3c7dc) waits for plugin MCP discovery and inventory publication before capturing a turn's tools. The Linux source build passes plugin-only invocation in the affected environment, including 6-second and 11-second inventory delays. This is a pinned community source build based on MiniMax's public 0.7.2 synchronization branch; the official npm package does not contain this patch. [Upstream issue #570](https://github.com/MiniMax-AI/minimax-code/issues/570) tracks adoption. + +Use Node.js 22.23.3 and the repository's pinned pnpm version to build the launcher: + +```bash +git clone --branch fix/plugin-mcp-turn-readiness-0.7.2 https://github.com/hidb4ai/minimax-code.git +cd minimax-code +git checkout 0ea69bd04c90cc151cae2eff9d5b7b4465d3c7dc +pnpm install --frozen-lockfile +pnpm build +powercontext setup minimax --source /path/to/powercontext \ + --minimax-data-dir "/path/to/MiniMax data" \ + --minimax-command "$PWD/dist/cli.js" +``` + +Launch this same `dist/cli.js` with `MINIMAX_DATA_DIR` set to the selected data directory. Setup saves that launcher for subsequent configuration and diagnostics. Keep the plugin enabled and its URL/credentials synchronized before choosing to disable the private entry for plugin-only execution. Returning to an unpatched launcher restores the official-version qualification limit. See the [acceptance record](https://github.com/oceanbase/powercontext/blob/master/integrations/minimax/acceptance.md) for the source revisions and validation boundary. + +## Configure a protected or remote Server + +Setup defaults to the local Server or preserves an existing native MCP endpoint. To select a different endpoint: + +```bash +powercontext setup minimax --source /path/to/powercontext \ + --server-url https://memory.example +``` + +Supply credentials through `POWERCONTEXT_MINIMAX_AUTHORIZATION`, or the shared `POWERCONTEXT_CLIENT_API_TOKEN`, in the process environment or setup `--env-file`. The installer stores a literal Bearer header in `/mcp.json` and synchronizes enabled private connection settings into the installed `plugins/powercontext/powercontext.mcp.json`. The source/distributed package and installation record contain no credentials. Enter secrets without exposing them in shell history. On Linux/macOS, setup creates or updates these connection files with owner-only permissions; an unchanged disabled private configuration retains its existing permissions. Protect both installed connection files with appropriate ACLs on Windows. + +Setup merges only the PowerContext server entry, preserving other MCP servers, unknown fields, headers, and an existing disabled preference. Existing credentials are retained. Upgrades also retain the installed plugin connection when the private entry is absent or disabled. A disabled private entry keeps its URL unless an endpoint is explicitly supplied through `--server-url` or the supported setup URL environment/file settings. With no entry changes, its entire private configuration file is left byte-for-byte unchanged. Supplying only a new credential rotates that entry’s credential at its existing URL without enabling it. The setup/config result reports `private_mcp_state`; a disabled entry remains inactive until the user enables it, and doctor continues to probe the effective bundled connection. Moving an authenticated entry to another endpoint requires an explicit replacement credential; setup will not silently forward the old credential. Malformed private configuration fails before package installation. + +MiniMax exposes private and plugin MCP connections independently, potentially with names such as `powercontext` and `powercontext_2`. Setup/config synchronize their URLs and headers when the private entry is enabled, so both tool sets reach the selected Server. With a disabled private entry and no explicit endpoint, the plugin connection is preserved, and rotating a dormant private credential does not copy it into the active plugin connection. An explicit endpoint also configures the active plugin connection. Doctor reports mismatched enabled connections as a failure; rerun setup or `config agent minimax` to reconcile them. Configure literal URLs and headers; the supported private configuration does not expand `${TOKEN}` placeholders. Setup's remote HTTP consent governs PowerContext's own configuration and diagnostic requests; it does not bypass MiniMax's native HTTP/TLS restrictions. Prefer HTTPS. Doctor reads the native endpoint and headers, rather than treating a global PowerContext token as the host's credentials. + +## Reinstall, upgrade, and recover + +Repeat the setup command with the desired source/ref. The installer records its managed file hashes under the PowerContext data root, keyed by MiniMax data directory. It updates those files, removes unchanged obsolete managed files, and preserves user-created files and unrelated plugins. A modified managed file or a new package path colliding with user content, a directory, or a file used as its parent causes a conflict before replacement. The staged package is checked before publication; user-created files are not added to the managed inventory. + +An identical manually copied package can be adopted while keeping extra user files. A different unmanaged package is rejected. Back it up outside the MiniMax `plugins` directory, preserve its private connection settings, and reconcile custom changes before retrying. Do not leave a second PowerContext package under another immediate plugin directory. + +Package publication restores the prior package and ownership record on failure. Connection publication restores the prior private/shared configuration on failure. Setup reports a completed package installation separately if subsequent connection configuration fails. If interrupted publication leaves a recovery backup, restore the indicated backup before retrying. + +## Understand verification results + +- `package`: the native manifest and referenced local resources are present. +- `native`: MiniMax loads a disposable snapshot of the local `powercontext` plugin and its enablement state, checking the expected version and at least the required Skill/MCP counts. Counts do not establish resource names or an active MCP session. A successful command that omits the plugin fails this check. +- `server_liveness` / `server_readiness`: the selected Server responds and reports its readiness. +- `server_access`: the CLI accesses Server info with the effective native headers. It does not prove that MiniMax established a connection or invoked a tool. + +Files copied with no CLI are reported as installed with degraded native verification and a nonzero overall exit. For a desktop installation, install the supported CLI or pass an existing executable launcher with `--minimax-command`; package copying alone is not desktop qualification. Doctor does not install, rewrite configuration, enable plugins, or initialize the real data directory. The CLI writes runtime records only inside a private temporary snapshot, which is removed afterward. Verification needs temporary space for the plugin and a copy of the native state database/WAL used to read enablement; unrelated sessions and provider state are not imported into the diagnostic runtime. Unsupported native state formats fail explicitly. Before the first setup, supply `--minimax-data-dir` or a directory environment override when using a native launcher; doctor does not start that launcher against the real home directory to discover a target. + +Refresh the Local catalog with `Ctrl+R` in `/plugins`, or restart MiniMax, using the same data directory. Preserve user disablement during upgrades. To enable it deliberately, run the selected launcher's `plugin enable powercontext@local --json`, with the target data directory in `MINIMAX_DATA_DIR`, then rerun doctor. Restart after changing a connection that has already been used. Verify a real MCP tool result in MiniMax separately from the CLI checks. diff --git a/docs/en/docs/operate/connect-remote-server.md b/docs/en/docs/operate/connect-remote-server.md index 0d36ab6e90..95d15d1208 100644 --- a/docs/en/docs/operate/connect-remote-server.md +++ b/docs/en/docs/operate/connect-remote-server.md @@ -95,7 +95,7 @@ The CLI observes the selected configuration files and its own environment, not a `--patch` arguments or environment; use `/pc doctor` inside that session. Doctor reports unsupported native composition (including unsupported JSON5/includes in other hosts) as unknown/failed rather than claiming safety. -MiniMax and the generic Agent Plugin delegate MCP transport to the host and have no PowerContext setup subcommand +MiniMax and the generic Agent Plugin delegate MCP transport to the host. MiniMax supports `powercontext setup minimax` and `doctor minimax`; setup merges its private MCP configuration. The generic Agent Plugin has no PowerContext setup subcommand or independent HTTP client. Their host may have its own restrictions; this flag cannot override host policy. LangChain, LangGraph, Pydantic AI, and the Bub evaluation adapter expose client consent without adding a host installer. diff --git a/docs/zh/docs/integrations/capabilities.md b/docs/zh/docs/integrations/capabilities.md index b8ad40be7c..a11a7301a9 100644 --- a/docs/zh/docs/integrations/capabilities.md +++ b/docs/zh/docs/integrations/capabilities.md @@ -48,3 +48,4 @@ candidate_review 仅可列举和读取候选材料,不授予决策权限。 | bub | 评测 harness | 仅 master | — | — | — | memory_read
memory_write
source_capture
context_injection
flush_or_checkpoint | | dify | 框架适配器 | 实验性 | — | — | — | memory_read
memory_write
source_capture
context_injection
handoff
experience_read_or_generate
skill_read_or_generate
candidate_review | | kimi-code | Agent 宿主 | 实验性 | minimal, recommended, full | unsupported | — | memory_read
memory_write
source_capture
context_injection
persistent_scope_binding
work_contract
handoff
acknowledge
task_outcome
experience_read_or_generate
skill_read_or_generate
candidate_review
external_skill | +| minimax | Agent 宿主 | 实验性 | — | unsupported | — | acknowledge
artifact_publication
candidate_review
experience_read_or_generate
external_skill
handoff
memory_read
memory_write
persistent_scope_binding
scope_organization
skill_read_or_generate
source_capture
task_outcome
work_contract | diff --git a/docs/zh/docs/integrations/meta.json b/docs/zh/docs/integrations/meta.json index 3bd70ccd19..a072ec993b 100644 --- a/docs/zh/docs/integrations/meta.json +++ b/docs/zh/docs/integrations/meta.json @@ -12,6 +12,7 @@ "dsh", "kimi-code", "hermes", + "minimax", "openclaw", "opencode", "pi", diff --git a/docs/zh/docs/integrations/minimax.md b/docs/zh/docs/integrations/minimax.md new file mode 100644 index 0000000000..8f74cfd5c8 --- /dev/null +++ b/docs/zh/docs/integrations/minimax.md @@ -0,0 +1,100 @@ +--- +status: community +title: MiniMax Code +description: 安装 MiniMax 原生插件,区分包发现、Server 访问与宿主实际连接。 +--- + +# MiniMax Code + +`community` · 实验性 + +PowerContext 安装 MiniMax 原生插件,包含 `powercontext-project-context` Skill 和 streamable HTTP MCP 连接。MCP 会话由 MiniMax 管理。插件不负责启动 Server,也不会自动采集全部对话。 + +## 前提与版本 + +需要 Python 3.11+、PowerContext CLI 和运行中的 PowerContext Server。CLI、Server 和插件应来自同一仓库 revision。使用发布版本时,通过 `--ref` 指定与 Server 匹配的仓库 tag;插件 manifest 版本独立于 Python 包版本。远程 ref 沿用现有分支和标签约定;安装指定 commit 时,先在本地检出,再使用 `--source`。 + +原生测试覆盖官方 npm CLI `@minimax-ai/code` 的 0.6.9 和 0.7.2,环境为 Linux x86_64、Node.js 22.23.3。这两个版本的仅插件 MCP 路径均尚未通过可靠性验收:宿主可能报告插件已连接,但模型无法获取工具,包括通过 `tool_search` 发现工具。升级至 0.7.2,或 setup、doctor 通过,都不能排除这一限制。桌面端发现和其他平台需要单独验收。具体命令、诊断产物和证据边界见[验收记录](https://github.com/oceanbase/powercontext/blob/master/integrations/minimax/acceptance.md)。 + +已验证的官方 0.7.2 安装流程同时保留 setup 创建的已启用私有 `powercontext` MCP 条目和完整插件:通过私有连接提供 MCP 工具,并保留插件 Skill。在官方 npm 版本中删除或禁用私有条目后,会依赖尚未通过验收的仅插件路径。setup 会保留既有禁用偏好;如果选择使用这条已验证路径,请在 `mcp.json` 中显式启用该私有条目,再运行 `powercontext config agent minimax` 并重启 MiniMax。 + +## 安装与选择目标 + +```bash +powercontext setup minimax + +# 仓库根目录或原生插件目录 +powercontext setup minimax --source /path/to/powercontext + +# 使用包含插件、且与 Server 匹配的分支或标签 +powercontext setup minimax --source oceanbase/powercontext --ref + +# 显式指定 MiniMax 数据根目录和可执行启动程序 +powercontext setup minimax --source /path/to/powercontext \ + --minimax-data-dir "/path/to/MiniMax data" \ + --minimax-command /path/to/mcode +``` + +插件安装到 `<数据根目录>/plugins/powercontext`,包含隐藏文件、Skill 引用、图标和许可证。目录优先级为 `--minimax-data-dir`、非空 `MINIMAX_DATA_DIR`、非空 `MAVIS_DATA_DIR`、已保存的安装目标。均未提供时,安装器通过所选 CLI 的 `plugin marketplace list --json` 查询本地目录,遵循宿主的 profile/build 选择。缺少 CLI 时默认使用 `~/.minimax`;此时若设置了 `MAVIS_PROFILE`,必须显式提供数据根目录。旧版或自定义 build 的 marketplace 输出无法识别时,也需要 `--minimax-data-dir`。 + +启动程序依次来自 `--minimax-command`、相同数据根目录保存的启动程序、PATH 中的 `mcode`。显式或已保存的启动程序无效时,在安装前失败。启动 MiniMax 时应使用所选数据目录。原生发现检查在该安装的临时快照中执行,并带入本地插件的启用状态。setup 保存目标,供后续 `doctor minimax` 和 `config agent minimax` 使用;目录环境变量仍优先于保存的目标。 + +交互式 `powercontext setup` 包含 MiniMax,多宿主安装复用相同行为: + +```bash +powercontext setup codex minimax --source /path/to/powercontext --json +powercontext doctor minimax --json +``` + +## 使用仅插件路径的源码修复 + +[MiniMax 就绪性修复](https://github.com/hidb4ai/minimax-code/commit/0ea69bd04c90cc151cae2eff9d5b7b4465d3c7dc)会在捕获本轮工具前,等待插件 MCP 发现及工具清单发布完成。该 Linux 源码构建已在原失败环境通过仅插件路径调用,包括工具清单延迟 6 秒和 11 秒的场景。这是基于 MiniMax 公开 0.7.2 同步分支的固定社区源码构建;官方 npm 包尚未包含此补丁。上游接纳进度见[问题单 #570](https://github.com/MiniMax-AI/minimax-code/issues/570)。 + +使用 Node.js 22.23.3 和仓库固定的 pnpm 版本构建启动器: + +```bash +git clone --branch fix/plugin-mcp-turn-readiness-0.7.2 https://github.com/hidb4ai/minimax-code.git +cd minimax-code +git checkout 0ea69bd04c90cc151cae2eff9d5b7b4465d3c7dc +pnpm install --frozen-lockfile +pnpm build +powercontext setup minimax --source /path/to/powercontext \ + --minimax-data-dir "/path/to/MiniMax data" \ + --minimax-command "$PWD/dist/cli.js" +``` + +启动同一个 `dist/cli.js`,并将 `MINIMAX_DATA_DIR` 设为所选数据目录。setup 会保存该启动器,供后续配置和诊断使用。选择禁用私有条目以运行仅插件路径前,应保持插件启用,并同步好 URL 和凭据。切回未修复的启动器后,仍受官方版本的验收限制。源码 revision 和验证范围见[验收记录](https://github.com/oceanbase/powercontext/blob/master/integrations/minimax/acceptance.md)。 + +## 配置受保护或远程 Server + +setup 默认连接本地 Server,或保留原生 MCP 配置中的现有 endpoint。切换连接时使用: + +```bash +powercontext setup minimax --source /path/to/powercontext \ + --server-url https://memory.example +``` + +通过进程环境或 setup 的 `--env-file` 提供 `POWERCONTEXT_MINIMAX_AUTHORIZATION`,也可使用共享的 `POWERCONTEXT_CLIENT_API_TOKEN`。安装器在 `<数据根目录>/mcp.json` 中保存字面量 Bearer header,并把已启用私有连接的设置同步到安装后的 `plugins/powercontext/powercontext.mcp.json`。源码分发包和安装记录不包含凭据。输入凭据时避免泄露到 shell 历史。在 Linux/macOS 上,新建或更新的这两个连接文件仅向所有者开放;未改变的禁用私有配置保留已有权限。Windows 上应使用适当的 ACL 保护这两个安装后的连接文件。 + +setup 只合并 PowerContext server 条目,保留其他 MCP server、未知字段、自定义 header 和已有禁用偏好。已有凭据会被保留。私有条目缺失或禁用时,升级也会保留插件当前的连接设置。私有条目已禁用时,只有通过 `--server-url` 或受支持的 setup URL 环境变量、环境文件显式指定端点,才会修改其 URL。条目无变化时,整个私有配置文件逐字节保留。只提供新凭据时,在原 URL 上轮换该条目的凭据,不自动启用它。setup/config 结果通过 `private_mcp_state` 标明私有条目状态;禁用条目需要用户启用后才生效,doctor 继续检查实际生效的插件内连接。把带认证条目迁移到另一 endpoint 时,必须显式提供替换凭据,避免自动转发旧凭据。私有配置无法解析时,在插件安装前失败。 + +MiniMax 独立暴露私有连接和插件内连接,工具名可能分别包含 `powercontext` 和 `powercontext_2`。私有条目启用时,setup/config 同步两套连接的 URL 和 header,让工具统一访问所选 Server。私有条目禁用且未显式指定端点时,保留插件内连接;仅轮换禁用条目的凭据不会把该凭据复制到活动插件连接。显式指定端点时,也会配置活动插件连接。doctor 对不一致的已启用连接报告失败,需要重新执行 setup 或 `config agent minimax` 进行同步。配置应使用字面量 URL 和 header;受支持的私有配置不展开 `${TOKEN}` 占位符。setup 的远程 HTTP 同意只控制 PowerContext 自身的配置和诊断请求,不会绕过 MiniMax 的原生 HTTP/TLS 限制,建议使用 HTTPS。doctor 从原生配置读取 endpoint 和 header,不把全局 PowerContext token 当成宿主已有凭据。 + +## 重复安装、升级与恢复 + +使用目标 source/ref 重复执行 setup。安装器在 PowerContext 数据根目录保存受管理文件的摘要,并按 MiniMax 数据目录区分安装目标。升级更新这些文件、删除未被修改的旧版受管理文件,并保留用户新增文件和其他插件。受管理文件被修改,或新版路径与用户文件、目录、作为父路径的文件冲突时,在替换前报告冲突。发布前检查暂存包;用户自建文件不会被加入受管清单。 + +与待安装包完全一致的手工安装可被接管,额外用户文件会保留。内容不同的未管理安装会被拒绝。请先备份到 MiniMax 的 `plugins` 目录之外,保留私有连接配置,并处理自定义修改后重试。不要在另一个直接子目录中保留重复的 PowerContext 插件。 + +包发布失败时恢复原包和归属记录;连接发布失败时恢复原私有配置和共享配置。若后续连接配置失败,setup 会单独说明插件安装是否已完成。中断后若出现恢复备份,应按提示恢复后再重试。 + +## 理解验证结果 + +- `package`:原生 manifest 及其本地引用资源完整。 +- `native`:MiniMax 加载本地 `powercontext` 插件及其启用状态的临时快照,检查版本匹配,并具有至少所需的 Skill/MCP 数量。数量不证明具体资源名称或 MCP 会话已经建立。命令成功但列表遗漏插件时,该检查仍失败。 +- `server_liveness` / `server_readiness`:所选 Server 可访问并报告就绪状态。 +- `server_access`:CLI 使用生效的原生 header 访问 Server info,不代表 MiniMax 已连接或调用工具。 + +缺少 CLI 时,复制的文件会报告为已安装,原生验证为降级,整体退出码非零。桌面安装需要安装受支持 CLI,或通过 `--minimax-command` 提供现有可执行启动程序;仅复制文件不等于桌面端验收。doctor 不安装包、不改写配置、不启用插件,也不初始化真实数据目录。原生 CLI 只在私有临时快照内维护运行记录,完成后删除快照。验证需要临时空间保存插件及用于读取启用状态的原生数据库/WAL 副本;不会把无关会话和 provider 状态导入诊断运行时。不支持的原生状态格式会明确报错。首次 setup 之前,使用原生启动程序进行诊断时应提供 `--minimax-data-dir` 或目录环境变量;doctor 不会通过启动真实家目录中的宿主来探测目标。 + +在 `/plugins` 中按 `Ctrl+R` 刷新 Local 列表,或使用同一数据目录重启 MiniMax。升级保留用户禁用状态。需要启用时,为所选启动程序设置目标 `MINIMAX_DATA_DIR`,执行 `plugin enable powercontext@local --json`,然后重新运行 doctor。修改已经使用过的连接后,应重启 MiniMax。宿主中的实际 MCP 工具结果需要与 CLI 检查分开验证。 diff --git a/docs/zh/docs/operate/connect-remote-server.md b/docs/zh/docs/operate/connect-remote-server.md index 97bdc08c5c..b73fb4d4d3 100644 --- a/docs/zh/docs/operate/connect-remote-server.md +++ b/docs/zh/docs/operate/connect-remote-server.md @@ -85,7 +85,7 @@ Profile 和凭据使用同一个 `DSH_HOME`;未设置、空值或纯空格均 独立 CLI 只能观察所选配置文件和自身环境,无法观察运行中 DSH 会话额外的 `--patch` 参数或环境;请在该会话内运行 `/pc doctor`。 对无法解析的原生配置组合(包括其他宿主未支持的 JSON5/include),doctor 会报告未知/失败,不会假定连接安全。 -MiniMax 和通用 Agent Plugin 由宿主负责 MCP 传输,没有单独的 PowerContext HTTP Client 或 setup 子命令; +MiniMax 和通用 Agent Plugin 由宿主负责 MCP 传输,没有单独的 PowerContext HTTP Client。MiniMax 支持 `powercontext setup minimax` 和 `doctor minimax`,安装器合并私有 MCP 配置;通用 Agent Plugin 没有 setup 子命令。 本选项不能绕过宿主自己的限制。LangChain、LangGraph、Pydantic AI 及 Bub 评测适配器支持客户端显式同意, 但不会因此增加 Agent 安装器。 diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index d030e45beb..c4d5a9e2ee 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -18,7 +18,6 @@ schema_version = 2 [directory_exclusions] cindy = "Scoped Ghost recall and will-user-message hooks plus native MCP under evaluation with Cindy 0.1.115; opt-in Codex app-server acceptance does not qualify Electron installation, credential UI, or complete Desktop harness routing. See integrations/cindy/README.md." agent-plugin = "Portable Agent Plugin client package, not a qualified host or framework adapter; host support is tracked by the individual host declarations. See integrations/agent-plugin/README.md." -minimax = "Host package awaiting one-command setup and native qualification under #1840; not yet included in the supported host catalog. See integrations/minimax/README.md." opendal = "Independently deployed evaluation Connector worker, not a complete standard Source integration or host adapter. See integrations/opendal/README.md." # These kinds have a different documentation contract; declared evidence is still checked. @@ -1319,3 +1318,17 @@ mode = "unsupported" implementation = ["integrations/kimi-code/plugins/powercontext/kimi.plugin.json", "integrations/kimi-code/plugins/powercontext/hooks/user_prompt_submit.mjs", "integrations/kimi-code/plugins/powercontext/scripts/scope.mjs", "src/powercontext/cli/kimi_code.py"] documentation = ["docs/en/docs/integrations/kimi-code.md", "docs/zh/docs/integrations/kimi-code.md"] tests = ["tests/test_cli_kimi_code.py", "integrations/kimi-code/plugins/powercontext/tests/plugin.test.mjs", "tests/e2e/test_kimi_code_host.py"] + +[[integrations]] +id = "minimax" +kind = "agent_host" +availability = "experimental" +capabilities = ["acknowledge", "artifact_publication", "candidate_review", "experience_read_or_generate", "external_skill", "handoff", "memory_read", "memory_write", "persistent_scope_binding", "scope_organization", "skill_read_or_generate", "source_capture", "task_outcome", "work_contract"] +profiles = [] +toolsets = ["server-mcp"] +[integrations.lifecycle.pre_compaction] +mode = "unsupported" +[integrations.evidence] +implementation = ["src/powercontext/cli/minimax.py", "integrations/minimax/plugins/powercontext/.minimax-plugin/plugin.json", "integrations/minimax/plugins/powercontext/powercontext.mcp.json"] +documentation = ["docs/en/docs/integrations/minimax.md", "docs/zh/docs/integrations/minimax.md", "integrations/minimax/acceptance.md"] +tests = ["tests/test_cli_minimax.py", "tests/e2e/test_minimax_host.py"] diff --git a/integrations/minimax/README.md b/integrations/minimax/README.md index cb27520ddd..bc5294ae8d 100644 --- a/integrations/minimax/README.md +++ b/integrations/minimax/README.md @@ -2,6 +2,15 @@ [`plugins/powercontext`](plugins/powercontext) contains the PowerContext plugin for MiniMax Code. See the [package README](plugins/powercontext/README.md) for server setup, usage, and troubleshooting. +## Install with PowerContext + +```bash +powercontext setup minimax --source /path/to/powercontext +powercontext doctor minimax --json +``` + +Use `--minimax-data-dir` for an explicit MiniMax data root and `--minimax-command` for an executable launcher outside PATH. See the [English guide](../../docs/en/docs/integrations/minimax.md), [中文指南](../../docs/zh/docs/integrations/minimax.md), and [native acceptance record](acceptance.md) for directory precedence, private configuration, upgrades, and verification limits. + ## Load locally From the repository root, copy the package into a separate MiniMax data directory: @@ -19,7 +28,7 @@ This command exercises MiniMax Code's native manifest loader. Inspect the listin MiniMax's public [`validate.mjs`](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/blob/main/scripts/validate.mjs) checks portable Agent Plugins with a root `plugin.json`. It does not validate this package's native `.minimax-plugin/plugin.json` format. -For regular use, copy the package to `plugins/powercontext` under your MiniMax data directory. The default is `~/.minimax`; `MINIMAX_DATA_DIR` overrides it. Preserve any existing user configuration when updating the package. +For regular use, copy the package to `plugins/powercontext` under your MiniMax data directory. The standard default is `~/.minimax`; profiles/builds may select another directory. `MINIMAX_DATA_DIR` overrides it, with `MAVIS_DATA_DIR` as a fallback. Preserve any existing user configuration when updating the package. ## Maintain the package diff --git a/integrations/minimax/acceptance.md b/integrations/minimax/acceptance.md new file mode 100644 index 0000000000..e29abb0a11 --- /dev/null +++ b/integrations/minimax/acceptance.md @@ -0,0 +1,123 @@ +# MiniMax native qualification + +## Environment + +- Date: 2026-10-31. +- Platform: Linux x86_64. +- Node.js: 22.23.3. +- Host: official npm `@minimax-ai/code@0.6.9` and `@minimax-ai/code@0.7.2`, installed into separate isolated temporary prefixes. +- PowerContext: working-tree CLI and Server, with SQLite and a synthetic local Bearer credential. +- Targets: isolated temporary data directories, including spaces and Chinese characters. No personal MiniMax account, provider credentials, or user profile is used. + +## Run + +Install the CLI into an isolated prefix following the official [installation instructions](https://github.com/MiniMax-AI/minimax-code/blob/main/docs/installation.md), then run: + +```bash +POWERCONTEXT_MINIMAX_ACCEPTANCE_COMMAND=/absolute/path/to/mcode \ + uv run python -m pytest tests/e2e/test_minimax_host.py -q \ + --basetemp=/tmp/powercontext-minimax-acceptance +``` + +The tests start a real PowerContext HTTP Server and invoke the public setup/doctor commands with the real MiniMax executable. The loopback listener must be permitted by the execution environment. Without the explicit launcher environment variable, these opt-in tests skip; a skip is not qualification. + +## Qualification status + +The 2026-10-29 Linux reference run passed eleven scenarios against the real CLI 0.6.9: explicit destination, `MINIMAX_DATA_DIR`, `MAVIS_DATA_DIR`, invalid-package/authentication diagnostics, private and plugin MCP calls under both cold and preflight startup, disabled private overrides with and without Authorization, and diagnosis of an uninstalled target. They verify repeated setup, the native loader's enabled local plugin record, required Skill/MCP counts, saved-target reuse, native disablement and its preservation during setup, omission of an invalid native manifest, and rejection of a wrong private Bearer header by the real Server. + +**Plugin-only tool injection in the unmodified official npm releases remains unqualified for reliable use.** The affected Linux environment reproduces the failure with both official CLI 0.6.9 and 0.7.2 on Node.js 22.23.3: a connected, enabled plugin's PowerContext tools are unavailable to the model. The private-only cases pass in that environment. Local passing runs do not resolve this failure, and this record does not establish reliable dual-route support. + +Native discovery runs in a disposable profile containing the installed package and its enablement state. The real MiniMax profile is unchanged byte-for-byte across doctor, both before and after disabling the plugin through the real CLI. Diagnosing an uninstalled target does not create that target. Only the selected plugin's enablement is carried into the diagnostic runtime; real sessions and provider configuration are not imported. + +The disabled-private scenarios configure the incoming package to use an anonymous loopback Server while retaining a disabled private HTTPS endpoint. First and repeated setup preserve every byte of the private file, including a dormant credential when present. Real native discovery and CLI Server probes succeed against the bundled endpoint, and doctor leaves the file unchanged. + +The native tool scenarios first verify that setup synchronized the private and installed plugin MCP URLs and Authorization headers. They exercise the default installation and independently isolate each connection before running `mcode exec` with a local OpenAI-compatible inference fixture: + +| Case | Private MCP configuration | Native plugin state | +| --- | --- | --- | +| `private-mcp` | Retain the enabled `powercontext` entry | Disable `powercontext@local` through the real CLI | +| `plugin-mcp` | Remove the `powercontext` entry from the temporary profile | Keep `powercontext@local` enabled | +| `installed-default` | Retain the enabled `powercontext` entry | Keep `powercontext@local` enabled | + +The real plugin list must confirm the expected enablement state. Each route runs with two startup modes. `cold` starts the native execution without a separate MCP preflight. `preflight` first uses an independent authenticated MCP client to initialize a real session and list tools at the configured endpoint, requiring `list_scopes` to be present. Preflight establishes Server MCP readiness; it does not populate the native process's connection cache or prove native tool injection. Each case uses a fresh Server database and native profile. + +For each isolated route, the inference fixture selects the single discovered `list_scopes` tool, either directly or through `tool_search` and `mcp_invoke`; empty, missing, or ambiguous tools fail qualification. The default installation may expose equivalent tools from both configured connections, so it selects one discovered `list_scopes` tool while still rejecting an empty or missing result. Tool aliases are not used to identify the route. A successful case requires the seeded Scope ID and title in the result associated with the emitted MCP tool-call ID. The result parser reads both the initial text block and appended MCP structured content. The plugin and default cases also require the installed Skill to reach the model; the private case does not require a Skill from its disabled plugin. These assertions establish an authenticated call for that execution, without an external model provider. + +The fixed five-round local matrix at `f4345d04` completed all twenty executions successfully: + +| Startup | Route | Successful executions | Maximum observed native `tools/list` response duration | +| --- | --- | --- | --- | +| Cold | Private MCP | 5/5 | 44.68 ms | +| Cold | Plugin MCP | 5/5 | 44.11 ms | +| Preflight | Private MCP | 5/5 | 34.81 ms | +| Preflight | Plugin MCP | 5/5 | 52.57 ms | + +Every execution returned the seeded Scope ID and title. The observed native response contained 56 tools, including `list_scopes`, and completed before the first model request carrying a tool catalog. Five local successes per combination establish this bounded reference result; they do not resolve the independently reported missing-tool failure. + +To repeat the same fixed matrix, preserve every run's output and temporary directory, including any failures: + +```bash +minimax_matrix_status=0 +for run in 1 2 3 4 5; do + POWERCONTEXT_MINIMAX_ACCEPTANCE_COMMAND=/absolute/path/to/mcode \ + uv run python -m pytest tests/e2e/test_minimax_host.py \ + -k 'controlled_inference and not installed-default' -q \ + --basetemp="/tmp/powercontext-minimax-acceptance-$run" \ + >"/tmp/powercontext-minimax-acceptance-$run.log" 2>&1 || minimax_matrix_status=1 +done +test "$minimax_matrix_status" -eq 0 +``` + +## Affected-environment results + +The original failing Linux environment ran the unchanged four-case controlled-inference matrix on 2026-10-30 after merging master `0c1dfc289b28e9f529555fb57bf771ce51103913`. Each official CLI version used a separate installation prefix; the host bundles were not patched and no discovery latency was injected. Each native `tools/list` response contained 63 tools, including `list_scopes`. + +| Official CLI | Private, cold | Private, preflight | Plugin, cold | Plugin, preflight | +| --- | --- | --- | --- | --- | +| 0.6.9 | Seeded Scope matched | Seeded Scope matched | No Scope tool | No Scope tool | +| 0.7.2 | Seeded Scope matched | Seeded Scope matched | No Scope tool | No Scope tool | + +CLI 0.6.9 exposed 81 tools in the private cases and only 18 built-ins in the plugin cases. CLI 0.7.2 exposed 20 tools and used deferred discovery through `tool_search` and `mcp_invoke`. The private cases discovered and invoked `list_scopes`, returning the seeded Scope ID and title; the plugin cases received an empty search result and failed the same Scope-tool assertion. An additional 0.7.2 cold plugin-only run configured both URLs with the canonical `/mcp/` path and still failed, so removing the HTTP redirect did not resolve the failure. + +The default installation with both the private MCP entry and the complete plugin enabled passed on official 0.7.2 under both cold and preflight startup. Each case discovered and invoked `mcp__powercontext__list_scopes` through `tool_search` and `mcp_invoke`, returned the seeded Scope ID and title associated with that tool call, and delivered `powercontext-project-context` to the model. This verifies the installed configuration and its private MCP fallback; it does not establish which connection would be selected after a future host fix or qualify the isolated plugin route. + +The shipped 0.7.2 runtime captures and retains a turn's capabilities before plugin discovery has necessarily published its inventory. A later successful inventory response does not replace that turn's captured capabilities. Its readiness method waits for initialization, cached restoration and directory watching; it does not wait for the background discovery/publication operation. Startup hooks also run after capability capture, so an independent MCP probe or a fixed delay in a hook does not establish a readiness barrier for that turn. The released host needs a discovery/publication barrier before final capability capture. PowerContext retains the failing isolated-route assertions; it does not patch an installed host, substitute the private route for the plugin test, or count an empty search result as acceptance. + +## Source fix in the affected environment + +The MiniMax source fix is available as [main-based commit `50ee352`](https://github.com/hidb4ai/minimax-code/commit/50ee352f15c5c3113b77f5020f03b32bcee2c3b8) and [0.7.2 source commit `0ea69bd`](https://github.com/hidb4ai/minimax-code/commit/0ea69bd04c90cc151cae2eff9d5b7b4465d3c7dc). Their respective upstream bases are `50448abe3c753391cbfb24df835518d844504862` and the public `feat/sync-0-7-2` branch at `0bd2c5a3f425a4e5fdafdf2eb954548e4d20fdfd`. These are community source builds, distinct from the official npm packages. [Upstream issue #570](https://github.com/MiniMax-AI/minimax-code/issues/570) tracks the fix and its adoption. + +The fix waits during turn preparation for the current MCP discovery and its inventory publication, then rechecks the generation at synchronous capability capture. The MCP readiness wait has a 20-second bound and supports cancellation without cancelling shared discovery. A server whose failed inventory has been published retains MiniMax's existing fail-open behavior: healthy tools remain available, and retry timers do not block a turn. Read-only package discovery keeps its existing behavior. + +Validation on 2026-10-31 used the same affected Linux environment, Node.js 22.23.3, pnpm 9.12.0, and PowerContext `319e389d3d61bb800cf3d8d7541e9e4522e54f7f`: + +- The unmodified main source build (version 0.6.5) reproduced the cold plugin-only failure without injected delay. Its inventory contained 63 tools and completed in about 264 ms, but no Scope tool reached the model. The same baseline also failed with an 11-second inventory delay. +- The main-based fixed build passed all six controlled-inference cases: private-only, plugin-only, and installed-default, each under cold and preflight startup. Every case returned the seeded Scope ID/title with the matching tool-call ID; the plugin and default cases delivered the Skill. +- The 0.7.2 source fix passed the complete thirteen-case real-host suite, including installation targets, loader omission, authorization failure, preservation of disabled private entries, all six controlled-inference cases, and read-only diagnosis of an uninstalled target. +- The 0.7.2 source fix passed cold plugin-only runs with 6-second and 11-second delays applied to the real `tools/list` response during native execution. Recorded inventory durations were 6023 ms and 11013 ms. The private MCP entry remained absent, and the tool-result assertions were unchanged. +- Eight new upstream regressions cover discovery and publication separately, waiter cancellation, a failed server alongside a healthy one, the readiness deadline, hot reload before capture, cached inventory with a retained older turn, and a Skill-only plugin. Seven fail on the unmodified source; all eight pass with the fix. The complete focused files pass on both source branches, including the existing Agent roster tests in 0.7.2. +- The main-based commit passed `pnpm verify` with the full applicable Linux profile on clean committed source, including source export, lint, type checking, build, artifact and capability checks, Code SDK tests, CLI smoke, offline BYOK, and permissions policy. Windows and macOS sandbox gates were inapplicable; release-archive installation was not exercised. The 0.7.2 source build and its focused turn-capability file passed; the full upstream verifier was run on the main-based commit. + +The [integration guide](../../docs/en/docs/integrations/minimax.md#run-the-plugin-only-source-fix) explains how to build the pinned 0.7.2 source and select its launcher through the existing `--minimax-command` option. This validates plugin-only execution with that source fix; it does not establish that an unmodified official npm release contains the fix, or qualify other platforms and external model providers. + +## Tool-discovery diagnostics + +Once the inference fixture starts, each case saves `acceptance-evidence.json` under its pytest temporary directory, including on assertion failure. The native execution also receives `--diagnostics-dir` pointing to `native-diagnostics/` in that directory. Keep these files when reporting a failure. The evidence contains the selected route and startup mode, Server preflight metadata when performed, native MCP request methods/statuses/durations, response tool count and presence of `list_scopes`, model-visible tool names, selected tools, the Scope-result match verdict, and discovery errors. Native request start/response completion and model receipt timestamps use the same Python process's monotonic clock in milliseconds, so their order can be compared directly. Requests without a tool catalog can be provider-negotiation probes; the evidence marks whether the `tools` field was present. It excludes request headers, tool arguments, and tool results. A request still open when the evidence is written may have no final duration. MiniMax's own diagnostics record execution and tool progress separately. Failures during setup or Server preflight occur before inference and are reported by pytest directly. + +Check the stages independently: Server MCP preflight, native `initialize` and `tools/list`, model-visible tool injection, then the authenticated `list_scopes` result. In the inspected official CLI 0.6.9 package, the connected-server log is emitted before fetching the tool inventory, so it does not establish tool availability. + +The two routes use different discovery mechanisms. The configured private HTTP route has a default 2-second per-turn discovery budget. The plugin route uses its own MCP runtime with a default 15-second `tools/list` budget. It initially publishes cached capabilities and schedules discovery in the background; its readiness method waits for cached restoration and the directory watcher, without waiting for that background discovery. These paths are also visible in the host's [MCP service](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/packages/local-runtime-v2/src/service/mcp/runtime/local-mcp.service.ts), [plugin MCP runtime](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/packages/local-runtime-v2/src/service/plugin-system/mcp/runtime.ts), and [plugin readiness implementation](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/packages/local-runtime-v2/src/service/plugin-system/plugin-system.ts); the corresponding behavior was checked against the installed npm bundle. `mcode exec --timeout 40s` controls overall execution and does not make the first model request wait for plugin discovery. No supported public readiness barrier or discovery-budget override has been established for this CLI build. + +A controlled experiment at `f4345d04` delayed only the successful `/mcp/` native `tools/list` response by 6 seconds, leaving the redirect and other MCP requests undelayed. The cold, plugin-only case failed its existing missing-tool assertion. The model received a catalog with 18 built-in tools and no PowerContext tools about 3.90 seconds after the request started. The Server then completed HTTP 200 in 6.04 seconds with 56 tools, including `list_scopes`. Thus the model used an incomplete catalog before the plugin discovery budget expired, even though the tools were returned successfully. The seeded Scope-result assertion remained unsatisfied. The zero-delay control with the same probe passed, exposing 74 tools (18 built-ins and 56 PowerContext tools) and returning the seeded Scope ID and title. A separate 11-second injected-delay run also failed with the inventory request still pending when evidence was saved. + +This experiment demonstrates a plugin startup race under injected latency; it does not establish that the independently reported natural failure has the same cause. Increasing the overall execution timeout, using a separate-process Server preflight, or accepting an empty tool catalog would not establish correct cold-start behavior. Stable plugin-route qualification requires diagnostics from the affected environment, confirmation of the failed stage, and a cold plugin-only invocation returning the seeded Scope ID and title after the corresponding fix. + +An isolated, unofficial readiness prototype waited for cached publication, active plugin discovery, and inventory publication before declaring the runtime ready. With the same injected-latency probe and unchanged assertions, both the 6-second and 11-second cold plugin-only cases returned the seeded Scope ID and title. A 16-second delay instead stopped startup with an explicit inventory-not-ready error and exit code 4, before any model request carrying a tool catalog. These results support a bounded discovery/publication barrier as a fix direction; they are not official CLI qualification. The prototype does not establish cancellation, hot-reload, or multi-server behavior, and the independent natural failure still requires evidence from its original environment. + +A local source-level fix was also verified against public MiniMax commit `da22f11c55d4748155d7162c1030414228a00565` (source build version `0.6.5`). It waits during turn preparation for in-flight discovery and inventory publication, supports cancellation and a 20-second readiness-wait limit, and rechecks readiness at final capability capture after hot reload. Ten focused regressions and the full Linux capability gate passed (`5559 passed, 18 skipped`); PowerContext's full real-host suite passed all eleven cases. Under the same 11-second injected delay, the unmodified source build failed and the fixed source build returned the seeded Scope. A 16-second delay failed explicitly with native exit code 4 before any model tool catalog. This is an uncommitted local source change, separate from the unchanged official npm CLI 0.6.9; it does not close the independent failure or establish a released-host fix. + +The package and connection checks use actual files, subprocesses, HTTP requests, and SQLite. Unit tests in `tests/test_cli_minimax.py` separately use simulated CLI output to cover parsing failures and installation recovery; those simulations are not native qualification. + +## Limits + +The plugin list reports resource counts rather than resource identities or an active MCP connection. CLI Server-info access is a separate HTTP probe; it does not establish a MiniMax MCP session. Desktop discovery, desktop refresh behavior, macOS, Windows, other CLI versions, and external model-provider calls require separate acceptance. Do not infer those capabilities from a successful Linux CLI run. diff --git a/integrations/minimax/plugins/powercontext/README.md b/integrations/minimax/plugins/powercontext/README.md index 43ea97e478..a11cb37136 100644 --- a/integrations/minimax/plugins/powercontext/README.md +++ b/integrations/minimax/plugins/powercontext/README.md @@ -43,7 +43,18 @@ uv run powercontext server run --env-file .env Keep the server running in its terminal. The plugin connects to `http://127.0.0.1:8000/mcp` by default. -Enable the PowerContext plugin in MiniMax Code. To check that MiniMax Code has discovered it, run: +Install the complete native plugin from the same checkout: + +```bash +uv run powercontext setup minimax --source . +uv run powercontext doctor minimax --json +``` + +For custom/profile installations, pass `--minimax-data-dir /path/to/data`; for a launcher outside PATH, pass `--minimax-command /path/to/mcode`. The data root is selected from that flag, `MINIMAX_DATA_DIR`, `MAVIS_DATA_DIR`, the saved target, or the selected CLI’s native marketplace directory. With no CLI or selected profile, the fallback is `~/.minimax`. Existing private MCP configuration and user files are preserved; conflicting manual or modified installations require migration before replacement. + +Files copied, native discovery, and CLI Server access are separate checks. Missing `mcode` leaves native verification incomplete and the overall command nonzero. The supported CLI qualification is recorded in the [MiniMax guide](https://powercontext.oceanbase.io/en/docs/integrations/minimax/); desktop discovery requires separate qualification. + +Refresh Local plugins with `Ctrl+R` in `/plugins`, or restart MiniMax using the selected data directory. Setup preserves existing disablement. To check that MiniMax Code has discovered the enabled package, run: ```bash mcode plugin list --marketplace local --json @@ -55,7 +66,26 @@ MiniMax Code uses its own account or model provider. Saving and reading explicit ## Connect to a protected server -The bundled connection uses a local server without authentication. If your deployment requires a Bearer token, configure the endpoint and token in MiniMax Code's private `mcp.json`: `~/.minimax/mcp.json`, or `$MINIMAX_DATA_DIR/mcp.json` when that variable is set. Merge this entry into any existing `mcpServers` object: +The distributed package defaults to a local server without authentication. For a protected deployment, supply `POWERCONTEXT_MINIMAX_AUTHORIZATION` through the process environment or a private environment file. Keep the file outside version control and restrict access to it. For example, `/path/to/private.env` can contain: + +```dotenv +POWERCONTEXT_MINIMAX_AUTHORIZATION= +``` + +Configure an installed plugin and check the result: + +```bash +uv run powercontext config agent minimax \ + --server-url https://your-powercontext-server.example \ + --env-file /path/to/private.env +uv run powercontext doctor minimax --json +``` + +Use `http://127.0.0.1:8000` for a protected server on the same machine. Use the same data directory as your installation: `MINIMAX_DATA_DIR` and the `MAVIS_DATA_DIR` fallback override the saved target. For a first installation, run `setup minimax --source .` with the same `--server-url` and `--env-file` options. + +MiniMax CLI 0.6.9 exposes private and plugin MCP connections independently, potentially as `powercontext` and `powercontext_2`. Setup/config synchronize their endpoints and headers so both tool sets reach the selected server. Restart MiniMax Code after changing the connection. Configuration does not automatically enable a disabled plugin or private entry. + +If you edit MiniMax Code's private configuration manually, merge this entry into the existing `mcpServers` object in `/mcp.json`: ```json { @@ -71,17 +101,24 @@ The bundled connection uses a local server without authentication. If your deplo } ``` -Replace the URL and token with your deployment values. Use `http://127.0.0.1:8000/mcp` for a protected server on the same machine. MiniMax Code 0.2.7 gives the enabled user-configured `powercontext` server precedence over the plugin's connection; the Skill remains available. Restart MiniMax Code after changing the configuration. +Replace the URL and token with your deployment values; use `http://127.0.0.1:8000/mcp` for a protected local server. After editing an enabled private entry, synchronize the installed plugin connection before restarting MiniMax Code: + +```bash +uv run powercontext config agent minimax +uv run powercontext doctor minimax --json +``` + +A disabled private entry stays disabled. To configure the active plugin connection while keeping that entry disabled, use the explicit `--server-url` and authorization flow above. -Keep this file private and out of version control; on Linux and macOS, restrict its permissions to `600`. Do not put the token in the plugin's `powercontext.mcp.json` or `plugin.json`. Enter the token value directly in the private configuration; a `${TOKEN}` placeholder will not load it from the environment. +Keep real tokens out of the source/distributed package, `plugin.json`, and examples. Setup/config store literal headers in the private `mcp.json` and synchronize them into the installed `plugins/powercontext/powercontext.mcp.json`; let these commands update the managed plugin connection. Keep both installed connection files private and out of version control. On Linux/macOS, setup/config apply owner-only permissions to updated connection files; restrict manually edited files and the credential environment file to mode `600`. On Windows, protect these files with appropriate ACLs. A `${TOKEN}` placeholder in the private JSON does not load a token from the environment. For server-side setup, see [Enable authentication](https://powercontext.oceanbase.io/en/docs/operate/deploy-server/#enable-authentication). The PowerContext server token authenticates MCP requests; model provider credentials belong in the server's model configuration. This package contains a Skill and native MCP configuration, with no local PowerContext HTTP client. MiniMax Code owns the MCP connection and its HTTP/TLS policy; `POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP` and PowerContext's saved client -settings do not control that native transport. Configure any remote endpoint -in MiniMax Code's private MCP configuration. Prefer HTTPS: HTTP transmits +settings do not control that native transport. Configure remote endpoints +through setup/config so both MCP connections are updated. Prefer HTTPS: HTTP transmits content and authorization headers without encryption. ## Your data @@ -92,9 +129,9 @@ The plugin saves context when requested; it does not automatically capture every ## Troubleshooting -If PowerContext is unavailable, check that the server is running and that MiniMax Code can reach `http://127.0.0.1:8000/mcp`. If the plugin is missing from the listing, check that it is installed and enabled in the MiniMax data directory you are using. +If PowerContext is unavailable, check that the server is running and that MiniMax Code can reach the configured endpoint (`http://127.0.0.1:8000/mcp` by default). If the plugin is missing from the listing, check that it is installed and enabled in the MiniMax data directory you are using. -For a custom endpoint, check the URL in your private MCP configuration. A `401` response means the server did not accept the credentials; check the Bearer token and restart MiniMax Code after updating it. A `403` response means the authenticated identity lacks permission for the requested operation. +For a custom endpoint, check the URL in your private MCP configuration. A `401` response means the server did not accept the credentials; correct the Bearer token, run the configuration and doctor commands above to synchronize both connections, then restart MiniMax Code. If doctor reports that the private and plugin MCP connections differ, run `config agent minimax` for the same data directory; editing only the private file does not update the plugin connection. A `403` response means the authenticated identity lacks permission for the requested operation. An empty search means no matching memory was found. Ask to save the relevant decision or constraint if you want it available later. A failed save remains incomplete; the plugin reports the failure so you can retry after resolving the connection or server issue. diff --git a/src/powercontext/cli/authorization.py b/src/powercontext/cli/authorization.py index 9943db4453..a991313401 100644 --- a/src/powercontext/cli/authorization.py +++ b/src/powercontext/cli/authorization.py @@ -40,6 +40,7 @@ "pi": "POWERCONTEXT_PI_AUTHORIZATION", "workbuddy": "POWERCONTEXT_WORKBUDDY_AUTHORIZATION", "dsh": "POWERCONTEXT_DSH_AUTHORIZATION", + "minimax": "POWERCONTEXT_MINIMAX_AUTHORIZATION", } _CODEX_AUTHORIZATION_ENVIRONMENT = "POWERCONTEXT_CODEX_AUTHORIZATION" diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index e7dd29ac1e..95cb16d0e0 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -337,14 +337,19 @@ def configure(host): options["dsh_target"] = resolve_dsh_target( DshProfile(profile) if profile is not None else None, dsh_command ) + installer = installers()[host](**options) if host == "minimax" else None + native = {"native_endpoint": installer.native_endpoint()} if installer is not None else {} connection = prepare_setup_transport( host, server_url=server_url, allow_insecure_http=allow_insecure_http, json_output=json_output, dsh_target=options.get("dsh_target"), + **native, ) - result = installers()[host](**options).configure(connection) + if installer is None: + installer = installers()[host](**options) + result = installer.configure(connection) return {"status": "configured", "result": result} report_agents(selected, configure, json_output=json_output) diff --git a/src/powercontext/cli/guidance.py b/src/powercontext/cli/guidance.py index 57606c8461..911b3d6677 100644 --- a/src/powercontext/cli/guidance.py +++ b/src/powercontext/cli/guidance.py @@ -109,7 +109,11 @@ def skill(self) -> Path: package_name="powercontext", ), "agent-plugin": HostGuidance("integrations/agent-plugin/powercontext"), - "minimax": HostGuidance("integrations/minimax/plugins/powercontext"), + "minimax": HostGuidance( + "integrations/minimax/plugins/powercontext", + source_files=(".minimax-plugin/plugin.json",), + package_name="powercontext", + ), } diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py index 6518bb6867..0141612efc 100644 --- a/src/powercontext/cli/hosts.py +++ b/src/powercontext/cli/hosts.py @@ -63,6 +63,8 @@ def validate_native_options(selected: Sequence[str], **options: Any) -> None: "boundary_flush": ("zcode",), "profile": ("dsh", "kimi-code"), "dsh_command": ("dsh",), + "minimax_data_dir": ("minimax",), + "minimax_command": ("minimax",), "runtime": ("kimi-code",), "mcp": ("kimi-code",), "cwd": ("kimi-code",), @@ -98,6 +100,7 @@ def native_options(host: str, **options: Any) -> dict[str, Any]: "dsh": ("dsh_target",), "kimi-code": ("scope_id", "capture_prompts", "workspace_scope", "profile"), "zcode": ("capture_prompts", "boundary_flush"), + "minimax": ("minimax_data_dir", "minimax_command"), } return {key: options[key] for key in fields.get(host, ()) if options.get(key) is not None} @@ -113,12 +116,18 @@ def doctor_checks(host: str, **options) -> dict[str, Any]: from powercontext.cli.dsh_runtime import resolve_dsh_target options["dsh_target"] = resolve_dsh_target(options.get("profile"), options.get("dsh_command")) - installer = installers()[host](**native_options(host, **options)) + target_options = native_options(host, **options) + if host == "minimax": + target_options["read_only"] = True + installer = installers()[host](**target_options) fields = {"kimi-code": ("runtime", "cwd", "session_id", "mcp"), "zcode": ("runtime_data_dir", "prepare")} checks = installer.verify(**{key: options[key] for key in fields.get(host, ()) if options.get(key) is not None}) except Exception as error: # Each target must produce a result even when native configuration is malformed. return {"verification": Diagnostic(DiagnosticStatus.FAILED, str(error))} try: + if host == "minimax": + checks.update(installer.server_checks()) + return checks target = {"dsh_target": installer.dsh_target} if host == "dsh" else {} endpoint, allowed = resolve_host_transport(host, **target) checks.update( @@ -158,7 +167,15 @@ def report_agents(selected: Sequence[str], action: Callable[[str], dict[str, Any else: for name, row in rows.items(): typer.echo(f"{name}: {row['status']}" + (" (setup complete)" if row.get("setup") == "complete" else "")) - for key in ("plugin_path", "package_path", "cache_dir", "profile", "profile_dir", "authorization_state"): + for key in ( + "plugin_path", + "package_path", + "cache_dir", + "profile", + "profile_dir", + "authorization_state", + "private_mcp_state", + ): if key in row.get("result", {}): typer.echo(f" {key}: {row['result'][key]}") if "error" in row: diff --git a/src/powercontext/cli/installation.py b/src/powercontext/cli/installation.py index 71272a8fb7..dc66683fea 100644 --- a/src/powercontext/cli/installation.py +++ b/src/powercontext/cli/installation.py @@ -21,7 +21,7 @@ import shutil import tempfile from collections.abc import Callable -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from dataclasses import dataclass from pathlib import Path from typing import Any @@ -120,6 +120,7 @@ def installers(): from powercontext.cli.dsh import DshInstaller from powercontext.cli.hermes import HermesInstaller from powercontext.cli.kimi_code import KimiInstaller + from powercontext.cli.minimax import MiniMaxInstaller from powercontext.cli.openclaw import OpenClawInstaller from powercontext.cli.opencode import OpenCodeInstaller from powercontext.cli.pi import PiInstaller @@ -133,6 +134,7 @@ def installers(): "dsh": DshInstaller, "hermes": HermesInstaller, "kimi-code": KimiInstaller, + "minimax": MiniMaxInstaller, "openclaw": OpenClawInstaller, "opencode": OpenCodeInstaller, "pi": PiInstaller, @@ -156,15 +158,25 @@ def setup( try: validate_setup_authorization(host) - connection = prepare_setup_transport( - host, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - dsh_target=options.get("dsh_target"), - ) - installer = installers()[host](**options) - report = installer.install(PluginSource(source, ref)) + installer = installers()[host](**options) if host == "minimax" else None + source_package = PluginSource(source, ref) + with source_package.package(host) if host == "minimax" else nullcontext() as package: + native = {"native_endpoint": installer.native_endpoint(package)} if installer is not None else {} + connection = prepare_setup_transport( + host, + server_url=server_url, + allow_insecure_http=allow_insecure_http, + json_output=json_output, + dsh_target=options.get("dsh_target"), + **native, + ) + if installer is None: + installer = installers()[host](**options) + report = ( + installer.install(source_package, connection=connection, package=package) + if host == "minimax" + else installer.install(source_package) + ) try: report.update(installer.configure(connection)) except Exception as error: diff --git a/src/powercontext/cli/minimax.py b/src/powercontext/cli/minimax.py new file mode 100644 index 0000000000..4190943a34 --- /dev/null +++ b/src/powercontext/cli/minimax.py @@ -0,0 +1,574 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ruff: noqa: TRY003 + +"""Install the native MiniMax package and inspect its effective connection.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shutil +import sqlite3 +import stat +import subprocess +import tempfile +from contextlib import closing, nullcontext +from pathlib import Path, PurePosixPath +from typing import Any, cast + +from powercontext.cli.authorization import normalize_authorization, setup_authorization_value +from powercontext.cli.installation import SetupError, publish +from powercontext.client.transport_policy import load_client_settings, normalize_client_url, resolve_client_transport +from powercontext.paths import powercontext_data_dir + +_MANIFEST = ".minimax-plugin/plugin.json" +_SKILL = "skills/powercontext-project-context/SKILL.md" +_MCP = "powercontext.mcp.json" + + +def _json_bytes(value: object) -> bytes: + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode() + + +def _object(path: Path) -> dict[str, Any]: + if path.is_symlink(): + raise SetupError("MiniMax configuration must not be a symbolic link") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, UnicodeError, ValueError): + raise SetupError("Cannot read MiniMax JSON configuration; repair it before setup") from None + if not isinstance(value, dict): + raise SetupError("MiniMax configuration must contain a JSON object") + return value + + +def _run(command: str, arguments: list[str], data_dir: Path | None = None, *, home: Path | None = None) -> object: + environment = dict(os.environ) + if data_dir is not None: + environment["MINIMAX_DATA_DIR"] = str(data_dir) + if home is not None: + environment.update(HOME=str(home), USERPROFILE=str(home)) + try: + result = subprocess.run( # noqa: S603 - the user selects the native launcher, without a shell. + [command, "plugin", *arguments, "--json"], + env=environment, + cwd=home, + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError, UnicodeError): + raise SetupError("MiniMax CLI could not run or timed out; check --minimax-command") from None + if result.returncode: + raise SetupError("MiniMax CLI failed; inspect the selected installation with mcode plugin --help") + try: + return json.loads(result.stdout) + except ValueError: + raise SetupError("MiniMax CLI returned invalid JSON; use a supported CLI build") from None + + +def _command(explicit: Path | None, saved: dict[str, Any], data_dir: Path | None) -> str | None: + configured = explicit or (saved.get("minimax_command") if str(data_dir) == saved.get("minimax_data_dir") else None) + if configured: + candidate = Path(configured).expanduser().absolute() + if not candidate.is_file() or not os.access(candidate, os.X_OK): + raise SetupError("MiniMax launcher is unavailable; correct --minimax-command") + return str(candidate) + discovered = shutil.which("mcode") + return str(Path(discovered).absolute()) if discovered is not None else None + + +def _data_dir(explicit: Path | None, saved: dict[str, Any], command: str | None, *, read_only: bool) -> Path: + override = ( + explicit or os.environ.get("MINIMAX_DATA_DIR", "").strip() or os.environ.get("MAVIS_DATA_DIR", "").strip() + ) + if override: + return Path(override).expanduser().resolve() + if saved.get("minimax_data_dir"): + return Path(saved["minimax_data_dir"]).expanduser().resolve() + if command: + if read_only: + raise SetupError("Specify --minimax-data-dir for read-only doctor before the first setup") + marketplaces = _run(command, ["marketplace", "list"]) + if isinstance(marketplaces, list): + local = [entry for entry in marketplaces if isinstance(entry, dict) and entry.get("name") == "local"] + path = local[0].get("path") if len(local) == 1 else None + if isinstance(path, str) and path: + return Path(path).expanduser().resolve().parent + raise SetupError("Cannot determine MiniMax data directory; specify --minimax-data-dir") + if os.environ.get("MAVIS_PROFILE", "").strip(): + raise SetupError("MiniMax profile requires --minimax-data-dir when mcode is unavailable") + return Path.home() / ".minimax" + + +def _relative(value: object) -> str: + if not isinstance(value, str) or not value or "\\" in value: + raise SetupError("MiniMax package contains an invalid resource path") + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or path.as_posix() != value or ":" in value: + raise SetupError("MiniMax package resource must stay inside its package") + return value + + +def _files(package: Path) -> dict[str, str]: + files = {} + for path in package.rglob("*"): + if path.is_symlink(): + raise SetupError("MiniMax package contains a symbolic link; resolve it before installation") + if path.is_file(): + files[path.relative_to(package).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + return files + + +def require_minimax_package(package: Path) -> dict[str, str]: + """Validate the native manifest and its complete, portable resources before writes.""" + manifest = _object(package / _MANIFEST) + if manifest.get("name") != "powercontext" or manifest.get("schemaVersion") != 1: + raise SetupError("Expected a schemaVersion 1 native powercontext MiniMax manifest") + resources = [_MANIFEST, "LICENSE", manifest.get("icon")] + for field, required in (("skills", _SKILL), ("mcpServers", _MCP)): + references = manifest.get(field) + if not isinstance(references, list) or required not in references: + raise SetupError("MiniMax manifest omits the required Skill or MCP configuration") + resources.extend(references) + for resource in resources: + relative = _relative(resource) + if not (package / relative).is_file(): + raise SetupError(f"MiniMax package is incomplete: missing {relative}") + config = _object(package / _MCP) + if config.get("schemaVersion") != 1: + raise SetupError("Unsupported MiniMax MCP configuration schema") + _mcp_entry(config, required=True) + guidance = _object(package / "skills/powercontext-project-context/.powercontext-guidance.json") + if guidance.get("schema_version") != 1 or not isinstance(guidance.get("resources"), list): + raise SetupError("MiniMax Skill resource inventory is missing") + for resource in guidance["resources"]: + path = package / "skills/powercontext-project-context" / _relative(resource) + if not path.is_file(): + raise SetupError("MiniMax Skill reference is missing") + return _files(package) + + +def _mcp_entry(config: dict[str, Any], *, required: bool = False) -> dict[str, Any] | None: + servers = config.get("mcpServers", {}) + if not isinstance(servers, dict): + raise SetupError("MiniMax mcpServers must be a JSON object") + entry = servers.get("powercontext") + if entry is None and not required: + return None + if not isinstance(entry, dict) or entry.get("type") != "streamable-http": + raise SetupError("MiniMax powercontext MCP entry must use streamable-http") + if not isinstance(entry.get("enabled", True), bool): + raise SetupError("MiniMax MCP enabled must be a boolean") + _endpoint(entry) + headers = entry.get("headers", {}) + if not isinstance(headers, dict) or any( + not isinstance(k, str) or not isinstance(v, str) for k, v in headers.items() + ): + raise SetupError("MiniMax MCP headers must be a string-valued object") + if any("${" in value for value in headers.values()): + raise SetupError("MiniMax private MCP headers must contain literal values") + authorization = [value for key, value in headers.items() if key.lower() == "authorization"] + if len(authorization) > 1: + raise SetupError("MiniMax MCP has duplicate Authorization headers") + if authorization: + normalize_authorization(authorization[0]) + return entry + + +def _endpoint(entry: dict[str, Any]) -> str: + value = entry.get("url") + if not isinstance(value, str) or "${" in value: + raise SetupError("MiniMax MCP URL must be a literal HTTP(S) endpoint") + endpoint = normalize_client_url(value) + if not endpoint.endswith("/mcp"): + raise SetupError("MiniMax powercontext MCP URL must end in /mcp") + return endpoint.removesuffix("/mcp") + + +def _verify_discovery(result: object, package: Path) -> None: + if not isinstance(result, dict) or not isinstance(result.get("installed"), list): + raise SetupError("MiniMax plugin list returned an unsupported schema") + result = cast(dict[str, Any], result) + matches = [ + entry + for entry in result["installed"] + if isinstance(entry, dict) and entry.get("name") == "powercontext" and entry.get("marketplaceName") == "local" + ] + if len(matches) != 1 or matches[0].get("installed") is not True: + raise SetupError( + "MiniMax omitted the installed local powercontext plugin; check its manifest and selected directory" + ) + plugin = matches[0] + if plugin.get("enabled") is not True: + raise SetupError( + "MiniMax powercontext plugin is disabled; enable powercontext@local in the selected MiniMax installation" + ) + manifest = _object(package / _MANIFEST) + if plugin.get("version") != manifest.get("version"): + raise SetupError("MiniMax discovered a different plugin version; refresh or restart the selected host") + counts = plugin.get("capabilities", {}) + if not isinstance(counts, dict) or any( + type(counts.get(key)) is not int or counts[key] < len(manifest[field]) + for key, field in (("skillCount", "skills"), ("mcpServerCount", "mcpServers")) + ): + raise SetupError("MiniMax did not discover the required Skill and MCP resources") + + +class MiniMaxInstaller: + def __init__( + self, *, minimax_data_dir: Path | None = None, minimax_command: Path | None = None, read_only: bool = False + ) -> None: + saved = load_client_settings("minimax") + override = ( + minimax_data_dir + or os.environ.get("MINIMAX_DATA_DIR", "").strip() + or os.environ.get("MAVIS_DATA_DIR", "").strip() + ) + selected = ( + Path(override).expanduser().resolve() + if override + else Path(saved["minimax_data_dir"]) + if saved.get("minimax_data_dir") + else None + ) + self.command = _command(minimax_command, saved, selected) + self.data_dir = _data_dir(minimax_data_dir, saved, self.command, read_only=read_only) + self.plugin = self.data_dir / "plugins/powercontext" + identity = hashlib.sha256(str(self.data_dir).encode()).hexdigest() + self.record = powercontext_data_dir() / "installations/minimax" / f"{identity}.json" + + def native_entry(self) -> dict[str, Any] | None: + """Read the private connection, or the bundle when the private entry is disabled.""" + entry = _mcp_entry(_object(self.data_dir / "mcp.json")) + if entry is not None and entry.get("enabled", True): + return entry + return _mcp_entry(_object(self.plugin / _MCP)) + + def native_endpoint(self, package: Path | None = None) -> str | None: + entry = self.native_entry() + if entry is None and package is not None: + private = _mcp_entry(_object(self.data_dir / "mcp.json")) + if private is not None and private.get("enabled") is False: + # A disabled private entry leaves the incoming bundle active on first install. + entry = _mcp_entry(_object(package / _MCP), required=True) + return _endpoint(entry) if entry is not None else None + + def install(self, source, *, connection=None, package: Path | None = None) -> dict[str, Any]: # noqa: C901 + self.native_entry() # Malformed private configuration must fail before publication. + if self.plugin.is_symlink() or self.plugin.parent.is_symlink(): + raise SetupError("MiniMax plugin installation must not replace a symbolic link") + with source.package("minimax") if package is None else nullcontext(package) as package: + incoming = require_minimax_package(package) + bundled = _object(package / _MCP) + if connection is not None: + _, _, selected_bundle, _ = self._connection_entries(connection, bundled) + bundled["mcpServers"]["powercontext"] = selected_bundle + previous = _object(self.record) + if previous and (previous.get("version") != 1 or previous.get("data_dir") != str(self.data_dir)): + raise SetupError("Unsupported MiniMax installation record; restore it before setup") + owned = previous.get("files", {}) + if not isinstance(owned, dict): + raise SetupError("Invalid MiniMax managed file inventory") + owned = {_relative(name): digest for name, digest in owned.items()} + current = _files(self.plugin) if self.plugin.is_dir() else {} + if self.plugin.exists() and not self.plugin.is_dir(): + raise SetupError("MiniMax plugin destination must be a directory") + if current and not previous: + if any(current.get(name) != digest for name, digest in incoming.items()): + raise SetupError( + "Unmanaged MiniMax installation differs; back it up outside plugins and migrate before setup" + ) + owned = incoming + for name in owned.keys() | incoming.keys(): + if (self.plugin / name).is_dir(): + raise SetupError(f"MiniMax installation conflict: {name} is a directory") + if any(parent.is_file() for parent in (self.plugin / name).parents if parent != self.plugin): + raise SetupError(f"MiniMax installation conflict: a parent of {name} is a file") + digest = current.get(name) + if digest is None and (self.plugin / name).is_file(): + raise SetupError(f"MiniMax installation conflict: {name} aliases an existing file") + if digest is not None and name not in owned: + raise SetupError(f"MiniMax installation conflict: {name} is a user-owned file") + if digest is not None and digest != owned.get(name) and digest != incoming.get(name): + raise SetupError(f"MiniMax installation conflict: {name}; preserve your changes before setup") + record = { + "version": 1, + "data_dir": str(self.data_dir), + "files": incoming, + "source": source.location, + "ref": source.ref, + } + self.plugin.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=self.plugin.parent) as temporary: + staged = Path(temporary) / "powercontext" + if self.plugin.exists(): + shutil.copytree(self.plugin, staged) + else: + staged.mkdir() + for name in owned.keys() - incoming.keys(): + (staged / name).unlink(missing_ok=True) + shutil.copytree(package, staged, dirs_exist_ok=True) + if connection is not None: + content = _json_bytes(bundled) + (staged / _MCP).write_bytes(content) + incoming[_MCP] = hashlib.sha256(content).hexdigest() + if os.name != "nt": + (staged / _MCP).chmod(0o600) + # Check the final projection rather than accepting copytree's directory semantics. + published = require_minimax_package(staged) + if any(published.get(name) != digest for name, digest in incoming.items()): + raise SetupError("MiniMax staged package differs from its source; check filesystem path collisions") + record["files"] = incoming + publish(replacements=[(staged, self.plugin)], writes={self.record: _json_bytes(record)}) + return {"plugin_path": str(self.plugin), "data_dir": str(self.data_dir), "files": "installed"} + + def _bundle_with_saved_connection(self, bundled): + """Apply installed connection choices to a fresh distribution's MCP declaration.""" + bundle_entry = _mcp_entry(bundled) + installed_bundle = _mcp_entry(_object(self.plugin / _MCP)) + if bundle_entry is not None and installed_bundle is not None: + bundle_entry = dict(bundle_entry) + bundle_entry.pop("headers", None) + bundle_entry.update({key: installed_bundle[key] for key in ("url", "headers") if key in installed_bundle}) + return bundle_entry + + def _connection_entries(self, connection, bundled): + config = _object(self.data_dir / "mcp.json") + existing = _mcp_entry(config) + bundle_entry = self._bundle_with_saved_connection(bundled) + preserve_disabled_endpoint = ( + existing is not None and existing.get("enabled") is False and not connection.endpoint_explicit + ) + endpoint = _endpoint(existing) if preserve_disabled_endpoint else connection.server_url + target_url = existing["url"] if preserve_disabled_endpoint else endpoint + "/mcp" + entry: dict[str, Any] = dict(existing or bundle_entry or {"type": "streamable-http"}) + headers = dict(entry.get("headers", {})) + authorization = setup_authorization_value("minimax") + state = "preserved" if any(key.lower() == "authorization" for key in headers) else "not_configured" + credential_source = existing or bundle_entry + if authorization and authorization.strip(): + headers = {key: value for key, value in headers.items() if key.lower() != "authorization"} + headers["Authorization"] = normalize_authorization(authorization) + state = "configured" + elif credential_source is not None and _endpoint(credential_source) != endpoint and state == "preserved": + raise SetupError( + "MiniMax endpoint changed; provide a new authorization explicitly before moving credentials" + ) + entry.update(type="streamable-http", url=target_url) + if headers: + entry["headers"] = headers + if bundle_entry is not None and not preserve_disabled_endpoint: + bundle_entry = dict(bundle_entry) + if entry.get("enabled") is not False: + bundle_entry.update(url=target_url) + if headers: + bundle_entry["headers"] = headers + else: + bundle_entry.pop("headers", None) + else: + bundle_headers = dict(bundle_entry.get("headers", {})) + if authorization and authorization.strip(): + bundle_headers = {k: v for k, v in bundle_headers.items() if k.lower() != "authorization"} + bundle_headers["Authorization"] = normalize_authorization(authorization) + elif _endpoint(bundle_entry) != endpoint and any( + key.lower() == "authorization" for key in bundle_headers + ): + raise SetupError("MiniMax endpoint changed; provide a new authorization explicitly") + bundle_entry.update(url=endpoint + "/mcp") + if bundle_headers: + bundle_entry["headers"] = bundle_headers + return config, entry, bundle_entry, state + + def configure(self, connection) -> dict[str, str]: + from powercontext.cli.transport import setup_transport_updates + + path = self.data_dir / "mcp.json" + bundled = _object(self.plugin / _MCP) + config, entry, bundle_entry, state = self._connection_entries(connection, bundled) + existing = _mcp_entry(config) + updates = setup_transport_updates(connection) + for key, content in list(updates.items()): + saved = json.loads(content) + saved["hosts"]["minimax"].update(minimax_data_dir=str(self.data_dir), minimax_command=self.command) + updates[key] = _json_bytes(saved) + modes = {} + # Leave unchanged disabled overrides untouched; active entries retain the + # existing owner-only permission handling during setup. + if existing != entry or entry.get("enabled") is not False: + config.setdefault("mcpServers", {})["powercontext"] = entry + updates[path] = _json_bytes(config) + if os.name != "nt": + modes[path] = stat.S_IMODE(path.stat().st_mode) & 0o600 if path.exists() else 0o600 + if bundle_entry is not None and bundle_entry != _mcp_entry(bundled): + bundled["mcpServers"]["powercontext"] = bundle_entry + content = _json_bytes(bundled) + updates[self.plugin / _MCP] = content + previous = _object(self.record) + if previous: + owned = previous.get("files", {}) + current = hashlib.sha256((self.plugin / _MCP).read_bytes()).hexdigest() + if ( + previous.get("version") != 1 + or previous.get("data_dir") != str(self.data_dir) + or not isinstance(owned, dict) + or owned.get(_MCP) != current + ): + raise SetupError( + "MiniMax installation conflict: preserve the modified MCP package before configuration" + ) + previous["files"][_MCP] = hashlib.sha256(content).hexdigest() + updates[self.record] = _json_bytes(previous) + if os.name != "nt": + modes[self.plugin / _MCP] = 0o600 + + def secure_files(): + for target, mode in modes.items(): + target.chmod(mode) + + publish(writes=updates, action=secure_files) + return { + "authorization_state": state, + "private_mcp_state": "disabled" if entry.get("enabled") is False else "enabled", + } + + def _native_snapshot(self): + """Run the native loader against a disposable copy of its local plugin state.""" + assert self.command is not None # noqa: S101 - verify handles an unavailable launcher. + with tempfile.TemporaryDirectory(prefix="powercontext-minimax-doctor-") as temporary: + home = Path(temporary) + data = home / "data" + plugin = data / "plugins/powercontext" + shutil.copytree(self.plugin, plugin) + database = self.data_dir / "v2/sqlite/runtime-state.sqlite" + if database.is_file(): + snapshot = home / "native-state.sqlite" + # Copy WAL too: opening the real database, even read-only, can create/update SHM. + for suffix in ("", "-wal"): + source = Path(str(database) + suffix) + if source.is_file(): + shutil.copyfile(source, Path(str(snapshot) + suffix)) + with closing(sqlite3.connect(snapshot)) as database_copy: + table = database_copy.execute( + "SELECT name FROM sqlite_master WHERE name = 'local_runtime_plugin_local_disabled'" + ).fetchone() + if table is None: + raise SetupError("Unsupported MiniMax native state; use a qualified CLI build") + disabled = database_copy.execute( + "SELECT 1 FROM local_runtime_plugin_local_disabled WHERE canonical_root = ?", + (str(self.plugin.resolve()),), + ).fetchone() + if disabled: + # Reproduce only enablement through the native API, never real sessions/providers. + _run(self.command, ["disable", "powercontext@local"], data, home=home) + return _run(self.command, ["list", "--marketplace", "local"], data, home=home) + + def _verify_connections(self): + private = _mcp_entry(_object(self.data_dir / "mcp.json")) + bundle = _mcp_entry(_object(self.plugin / _MCP), required=True) + if ( + private is not None + and private.get("enabled", True) + and ( + bundle is None + or _endpoint(private) != _endpoint(bundle) + or private.get("headers", {}) != bundle.get("headers", {}) + ) + ): + raise SetupError("MiniMax private and plugin MCP connections differ; rerun setup or config agent minimax") + + def verify(self): + from powercontext.cli.system import Diagnostic, DiagnosticStatus + + checks = {} + try: + require_minimax_package(self.plugin) + except (OSError, ValueError, SetupError) as error: + checks["package"] = Diagnostic(DiagnosticStatus.FAILED, str(error)) + checks["native"] = Diagnostic(DiagnosticStatus.SKIPPED, "Native check skipped: package is invalid") + return checks + else: + checks["package"] = Diagnostic( + DiagnosticStatus.OK, "Native manifest and all packaged resources are present" + ) + if self.command is None: + checks["native"] = Diagnostic( + DiagnosticStatus.DEGRADED, + "Files only: mcode is unavailable; install the CLI or set --minimax-command, then rerun doctor", + ) + return checks + try: + result = self._native_snapshot() + _verify_discovery(result, self.plugin) + self._verify_connections() + checks["native"] = Diagnostic( + DiagnosticStatus.OK, + "Native loader verified a read-only snapshot of the local plugin and enablement state; " + "host reports the required Skill/MCP counts " + "(not a connection test). Keep Server running; refresh /plugins or restart MiniMax using the selected data directory.", + ) + except (OSError, ValueError, SetupError, sqlite3.Error) as error: + checks["native"] = Diagnostic(DiagnosticStatus.FAILED, str(error)) + return checks + + def server_checks(self): + from powercontext.cli.system import Diagnostic, DiagnosticStatus, server_diagnostics + from powercontext.client import PowerContextClient + from powercontext.client.errors import ServerResponseError + + entry = self.native_entry() + if entry is None: + raise SetupError("MiniMax has no effective PowerContext MCP endpoint") + endpoint = _endpoint(entry) + endpoint, allowed = resolve_client_transport("minimax", server_url=endpoint) + checks = server_diagnostics(server_url=endpoint, allow_insecure_http=allowed) + + async def probe(): + import httpx + + # Only the native configuration supplies headers; never inherit a global token. + transport = httpx.AsyncHTTPTransport() + async with ( + httpx.AsyncClient( + headers=entry.get("headers", {}), transport=transport, timeout=3, follow_redirects=False + ) as http, + PowerContextClient(endpoint, http_client=http, allow_insecure_http=allowed) as client, + ): + await client.get_server_info() + + try: + asyncio.run(probe()) + except ServerResponseError as error: + checks["server_access"] = Diagnostic( + DiagnosticStatus.FAILED, + f"CLI Server access returned HTTP {error.status_code}; check native MCP credentials and permissions", + ) + except Exception: + checks["server_access"] = Diagnostic( + DiagnosticStatus.FAILED, "CLI could not check Server access using the native MCP configuration" + ) + else: + checks["server_access"] = Diagnostic( + DiagnosticStatus.OK, + "CLI reached Server info using native headers; MiniMax MCP connection and model calls were not tested", + ) + return checks diff --git a/src/powercontext/cli/native_transport.py b/src/powercontext/cli/native_transport.py index c9de87f064..a222ba9aee 100644 --- a/src/powercontext/cli/native_transport.py +++ b/src/powercontext/cli/native_transport.py @@ -174,6 +174,10 @@ def _selected_url(host: str, prefix: str, native_url: str | None) -> str: def configured_native_endpoint(host: str) -> str | None: """Read only explicitly configured native endpoints for setup conflict detection.""" + if host == "minimax": + from powercontext.cli.minimax import MiniMaxInstaller + + return MiniMaxInstaller().native_endpoint() if host == "codex": return _codex_url() if host == "zcode": @@ -185,12 +189,19 @@ def configured_native_endpoint(host: str) -> str | None: return _url(native[url_key]) if url_key in native else None -def resolve_host_transport(host: str, *, dsh_target: DshTarget | None = None) -> tuple[str, bool]: +def resolve_host_transport(host: str, *, dsh_target: DshTarget | None = None) -> tuple[str, bool]: # noqa: C901 - native transport precedence differs by host. """Resolve the effective endpoint and consent, or report an unknown native configuration. Native HTTP consent is endpoint-bound. This is a read-only diagnostic, not an HTTP guard; callers must label remote HTTP as degraded or blocked. """ + if host == "minimax": + from powercontext.cli.minimax import MiniMaxInstaller + + endpoint = MiniMaxInstaller().native_endpoint() + if endpoint is None: + raise ValueError("MiniMax PowerContext MCP endpoint is not configured") # noqa: TRY003 + return resolve_client_transport(host, server_url=endpoint) if host == "zcode": from powercontext.cli.zcode import zcode_plugin_dir diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 7ff21b25d0..7690619d11 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -209,6 +209,10 @@ def setup_agents( typer.Option(help="DSH profile or Kimi MCP profile: minimal, recommended, full; omission preserves it."), ] = None, dsh_command: Annotated[Path | None, typer.Option(help="DSH launcher path.")] = None, + minimax_data_dir: Annotated[ + Path | None, typer.Option(help="MiniMax data root; plugins/powercontext is added.") + ] = None, + minimax_command: Annotated[Path | None, typer.Option(help="MiniMax mcode launcher path.")] = None, env_file: Annotated[Path | None, typer.Option(help="Setup environment file; defaults to .env.")] = None, ) -> None: """Install, configure, activate and diagnose the selected agent integrations.""" @@ -231,6 +235,8 @@ def setup_agents( "boundary_flush": boundary_flush, "workspace_scope": workspace_scope or None, "profile": profile, + "minimax_data_dir": minimax_data_dir, + "minimax_command": minimax_command, } validate_native_options(selected, **options, dsh_command=dsh_command) @@ -270,6 +276,10 @@ def doctor( DshProfile | None, typer.Option(help="DSH profile; defaults to the saved profile or web.") ] = None, dsh_command: Annotated[Path | None, typer.Option(help="DSH launcher path.")] = None, + minimax_data_dir: Annotated[ + Path | None, typer.Option(help="MiniMax data root; defaults to the selected installation.") + ] = None, + minimax_command: Annotated[Path | None, typer.Option(help="MiniMax mcode launcher path.")] = None, runtime: Annotated[bool, typer.Option(help="Inspect Kimi Hook history.")] = False, mcp: Annotated[bool, typer.Option(help="Discover Kimi Server MCP tools without executing operations.")] = False, cwd: Annotated[Path | None, typer.Option(help="Kimi workspace for Scope resolution and runtime history.")] = None, @@ -309,6 +319,8 @@ def doctor( "runtime_data_dir": runtime_data_dir, "prepare": True if prepare else None, "dsh_command": dsh_command, + "minimax_data_dir": minimax_data_dir, + "minimax_command": minimax_command, } selected = select_hosts(agents, json_output=json_output) if agents else () validate_native_options(selected, **options) diff --git a/src/powercontext/cli/transport.py b/src/powercontext/cli/transport.py index a4e924682f..361dd51da8 100644 --- a/src/powercontext/cli/transport.py +++ b/src/powercontext/cli/transport.py @@ -50,6 +50,12 @@ class SetupTransport: host: str server_url: str allow_insecure_http: bool + endpoint_explicit: bool = False + + +def _setup_endpoint_environment_names(host: str) -> tuple[str, ...]: + prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" + return (prefix + "BASE_URL", prefix + "SERVER_URL", prefix + "ENDPOINT", "POWERCONTEXT_CLIENT_SERVER_URL") def is_remote_http(server_url: str) -> bool: @@ -66,12 +72,18 @@ def prepare_setup_transport( allow_insecure_http: bool | None = None, json_output: bool = False, dsh_target: DshTarget | None = None, + native_endpoint: str | None = None, ) -> SetupTransport: """Resolve and confirm the endpoint before any installation side effects.""" from powercontext.cli.system import SetupError try: + endpoint_explicit = server_url is not None or any( + values.get(name) + for values in (setup_environment(), os.environ) + for name in _setup_endpoint_environment_names(host) + ) native = {} if host == "dsh": from powercontext.cli.dsh_transport import read_dsh_settings @@ -82,7 +94,9 @@ def prepare_setup_transport( executable=dsh_target.command if dsh_target else None, ) prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" - server_url = resolve_setup_endpoint(host, server_url=server_url, native_endpoint=native.get("baseUrl")) + server_url = resolve_setup_endpoint( + host, server_url=server_url, native_endpoint=native_endpoint if host == "minimax" else native.get("baseUrl") + ) allow_insecure_http = _setup_consent(host, server_url, allow_insecure_http, native) endpoint, allowed = resolve_client_transport( host, server_url=server_url, allow_insecure_http=allow_insecure_http @@ -139,7 +153,7 @@ def prepare_setup_transport( validate_dsh_setup_transport(native, endpoint, allowed) except ValueError as error: raise SetupError(str(error)) from error - return SetupTransport(host, endpoint, allowed) + return SetupTransport(host, endpoint, allowed, endpoint_explicit=endpoint_explicit) def _setup_consent(host: str, endpoint: str, requested: bool | None, native: dict[str, Any]) -> bool | None: @@ -176,8 +190,7 @@ def setup_environment() -> dict[str, str]: def _explicit_setup_endpoint(host: str, server_url: str) -> str: """Honor a chosen URL only when inherited runtime overrides cannot undo it.""" endpoint = normalize_client_url(server_url).removesuffix("/mcp").rstrip("/") - prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" - keys = (prefix + "BASE_URL", prefix + "SERVER_URL", prefix + "ENDPOINT", "POWERCONTEXT_CLIENT_SERVER_URL") + keys = _setup_endpoint_environment_names(host) if host == "claude-code": keys += ("CLAUDE_PLUGIN_OPTION_SERVER_URL",) for key in keys: @@ -191,6 +204,8 @@ def _explicit_setup_endpoint(host: str, server_url: str) -> str: def _saved_setup_endpoints(host: str, native_endpoint: str | None) -> list[tuple[str, str]]: """Read saved endpoint fallbacks, using the supplied native URL for DSH.""" + if host == "minimax": + return [("native MiniMax settings", native_endpoint)] if native_endpoint else [] saved = load_client_settings(host).get("server_url") native = native_endpoint if host == "dsh" else existing_native_endpoint(host) return [ @@ -214,8 +229,7 @@ def resolve_setup_endpoint( """ if server_url is not None: return _explicit_setup_endpoint(host, server_url) - prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" - keys = (prefix + "BASE_URL", prefix + "SERVER_URL", prefix + "ENDPOINT", "POWERCONTEXT_CLIENT_SERVER_URL") + keys = _setup_endpoint_environment_names(host) loaded = setup_environment() candidates = [ (name, normalize_client_url(values[name]).removesuffix("/mcp").rstrip("/")) diff --git a/src/powercontext/client/transport_policy.py b/src/powercontext/client/transport_policy.py index 5fe8c91c42..2d359e1916 100644 --- a/src/powercontext/client/transport_policy.py +++ b/src/powercontext/client/transport_policy.py @@ -95,8 +95,22 @@ def load_client_settings(host: str) -> dict[str, Any]: if not isinstance(saved["allow_insecure_http"], bool): raise ValueError("Saved PowerContext allow_insecure_http must be a JSON boolean") # noqa: TRY003 result["allow_insecure_http"] = saved["allow_insecure_http"] + result.update(_host_target_settings(host, saved)) + return result + + +def _host_target_settings(host: str, saved: dict[str, Any]) -> dict[str, Any]: + """Keep saved target identity separate from connection and credential settings.""" if host == "dsh" and "profile" in saved: - result.update(profile=saved["profile"], dsh_command=saved.get("dsh_command")) + return {"profile": saved["profile"], "dsh_command": saved.get("dsh_command")} + result = {} + if host == "minimax": + for name in ("minimax_data_dir", "minimax_command"): + value = saved.get(name) + if value is not None: + if not isinstance(value, str) or not value.strip(): + raise ValueError("Saved MiniMax target paths must be non-empty strings") # noqa: TRY003 + result[name] = value return result diff --git a/tests/e2e/test_minimax_diagnostics_http.py b/tests/e2e/test_minimax_diagnostics_http.py new file mode 100644 index 0000000000..3e30fddc75 --- /dev/null +++ b/tests/e2e/test_minimax_diagnostics_http.py @@ -0,0 +1,166 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The public MiniMax doctor must enforce HTTP consent before sending credentials.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[2] + + +@pytest.fixture +def diagnostic_http_server(): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: # noqa: A002 - match the HTTP handler interface. + pass + + def do_GET(self): + authorized = self.headers.get("Authorization") == "Bearer synthetic-minimax-token" + requests.append((self.path, authorized)) + if self.path == "/v1/server-info": + if not authorized: + self.send_error(401) + return + payload = { + "schema_version": {"major": 1, "minor": 0}, + "product": "powercontext", + "server_id": "diagnostic-fixture", + "package_version": "1.4.1", + "api_contract_version": {"major": 1, "minor": 0}, + "feature_contracts": {}, + } + elif self.path == "/health/live": + payload = {"status": "ok"} + elif self.path == "/health/ready": + payload = {"status": "ready", "checks": {}} + else: + self.send_error(404) + return + content = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://minimax-diagnostics.invalid:{server.server_port}", requests + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + assert not thread.is_alive() + + +@pytest.mark.parametrize("private_enabled", [False, True], ids=["plugin", "private"]) +@pytest.mark.parametrize( + "override", + ["saved", "other-endpoint", "client-false", "client-invalid", "minimax-false", "minimax-invalid"], +) +def test_doctor_enforces_consent_before_any_http_request(tmp_path, diagnostic_http_server, private_enabled, override): + endpoint, requests = diagnostic_http_server + profile = tmp_path / "minimax" + plugin = profile / "plugins/powercontext" + shutil.copytree(ROOT / "integrations/minimax/plugins/powercontext", plugin) + entry = { + "type": "streamable-http", + "url": endpoint + "/mcp", + "headers": {"Authorization": "Bearer synthetic-minimax-token"}, + } + (profile / "mcp.json").write_text( + json.dumps({"mcpServers": {"powercontext": {**entry, "enabled": private_enabled}}}) + ) + bundle_path = plugin / "powercontext.mcp.json" + bundle = json.loads(bundle_path.read_bytes()) + bundle["mcpServers"]["powercontext"] = entry + bundle_path.write_text(json.dumps(bundle)) + settings = tmp_path / "clients.json" + settings.write_text( + json.dumps({ + "version": 1, + "hosts": { + "minimax": { + "server_url": "http://previous.invalid:8000" if override == "other-endpoint" else endpoint, + "allow_insecure_http": True, + "minimax_data_dir": str(profile), + } + }, + }) + ) + site = tmp_path / "python-site" + site.mkdir() + # Keep a non-loopback URL for policy checks while routing all test HTTP to this listener. + (site / "sitecustomize.py").write_text( + "import socket\n" + "original = socket.getaddrinfo\n" + "def mapped(host, *args, **kwargs):\n" + " if host in ('minimax-diagnostics.invalid', b'minimax-diagnostics.invalid'):\n" + " host = '127.0.0.1'\n" + " return original(host, *args, **kwargs)\n" + "socket.getaddrinfo = mapped\n" + ) + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("POWERCONTEXT_", "MINIMAX_", "MAVIS_")) and not key.lower().endswith("_proxy") + } + environment.update( + POWERCONTEXT_HOME=str(tmp_path / "pc"), + POWERCONTEXT_CLIENT_CONFIG_FILE=str(settings), + MINIMAX_DATA_DIR=str(profile), + PYTHONPATH=os.pathsep.join((str(site), str(ROOT / "src"))), + PATH="", + NO_PROXY="*", + ) + if "-" in override and override != "other-endpoint": + host, value = override.split("-", 1) + environment[f"POWERCONTEXT_{host.upper()}_ALLOW_INSECURE_HTTP"] = value + before = { + path: path.read_bytes() for path in [settings, *plugin.rglob("*"), profile / "mcp.json"] if path.is_file() + } + result = subprocess.run( + [sys.executable, "-c", "from powercontext.cli.app import main; main()", "doctor", "minimax", "--json"], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + checks = json.loads(result.stdout)["hosts"]["minimax"]["checks"] + if override == "saved": + assert checks["server_access"]["status"] == "ok", result.stdout + assert requests == [("/health/live", False), ("/health/ready", False), ("/v1/server-info", True)] + else: + assert result.returncode != 0, result.stdout + assert requests == [] + assert checks.get("server_access", {}).get("status") != "ok" + assert {path: path.read_bytes() for path in before} == before + assert "synthetic-minimax-token" not in result.stdout + result.stderr diff --git a/tests/e2e/test_minimax_host.py b/tests/e2e/test_minimax_host.py new file mode 100644 index 0000000000..91ad188e95 --- /dev/null +++ b/tests/e2e/test_minimax_host.py @@ -0,0 +1,649 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Opt-in qualification against a real MiniMax CLI and authenticated SQLite Server.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import socket +import subprocess +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import httpx +import pytest +import uvicorn +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport +from pydantic import SecretStr +from typer.testing import CliRunner + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.cli.app import create_cli +from powercontext.server.factory import create_server_app +from powercontext.server.settings import BearerAuthConfig, ServerSettings + +COMMAND = os.environ.get("POWERCONTEXT_MINIMAX_ACCEPTANCE_COMMAND", "") +pytestmark = pytest.mark.skipif( + not COMMAND, reason="Set POWERCONTEXT_MINIMAX_ACCEPTANCE_COMMAND to a real mcode launcher" +) +ROOT = Path(__file__).parents[2] + + +def _tool_listing(body): + """Read tool names from JSON or streamable HTTP SSE, excluding tool results.""" + text = body.decode("utf-8").replace("\r\n", "\n") + documents = [text] + if text.startswith(("event:", "data:", ":")): + documents = [ + "\n".join(line[5:].lstrip(" ") for line in event.splitlines() if line.startswith("data:")) + for event in text.split("\n\n") + ] + for document in documents: + try: + value = json.loads(document) + except ValueError: + continue + if isinstance(value, dict) and isinstance(value.get("result"), dict): + tools = value["result"].get("tools") + if isinstance(tools, list): + return [tool["name"] for tool in tools] + return None + + +class MCPTrace: + """Retain protocol timing without recording headers, arguments, or results.""" + + def __init__(self, app, events): + self.app = app + self.events = events + + @staticmethod + def record_request(event, body): + try: + document = json.loads(body) + except ValueError: + return + event["rpc_method"] = document.get("method") + if document.get("method") == "tools/call": + event["tool_name"] = document.get("params", {}).get("name") + + @staticmethod + def finish_response(event, response): + event["response_finished_at_ms"] = round(time.monotonic() * 1000, 2) + if event.get("rpc_method") == "tools/list": + names = _tool_listing(response) + if names is not None: + event["response_tool_count"] = len(names) + event["response_has_list_scopes"] = "list_scopes" in names + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or not scope["path"].startswith("/mcp"): + return await self.app(scope, receive, send) + started = time.monotonic() + event = {"http_method": scope["method"], "started_at_ms": round(started * 1000, 2)} + self.events.append(event) + body = bytearray() + response = bytearray() + + async def observed_receive(): + message = await receive() + if message["type"] == "http.request": + body.extend(message.get("body", b"")) + if not message.get("more_body", False): + self.record_request(event, body) + return message + + async def observed_send(message): + if message["type"] == "http.response.start": + event["http_status"] = message["status"] + if message["type"] == "http.response.body" and event.get("rpc_method") == "tools/list": + response.extend(message.get("body", b"")) + await send(message) + if message["type"] == "http.response.body" and not message.get("more_body", False): + self.finish_response(event, response) + + try: + await self.app(scope, observed_receive, observed_send) + finally: + event["elapsed_ms"] = round((time.monotonic() - started) * 1000, 2) + + +@pytest.fixture +def mcp_trace(): + return [] + + +@pytest.fixture +def host(tmp_path, monkeypatch, request, mcp_trace): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "pc")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + authenticated = getattr(request, "param", True) + if authenticated: + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "acceptance-only-token") + else: + monkeypatch.delenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", raising=False) + for name in ("MINIMAX_DATA_DIR", "MAVIS_DATA_DIR", "MAVIS_PROFILE"): + monkeypatch.delenv(name, raising=False) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"), + auth=BearerAuthConfig( + enabled=authenticated, token=SecretStr("acceptance-only-token") if authenticated else None + ), + ), + scheduler_path=tmp_path / "scheduler", + ) + app.add_middleware(MCPTrace, events=mcp_trace) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + try: + deadline = time.monotonic() + 15 + while not server.started and thread.is_alive() and time.monotonic() < deadline: + time.sleep(0.05) + assert server.started + yield tmp_path, f"http://127.0.0.1:{port}" + finally: + server.should_exit = True + thread.join(timeout=15) + assert not thread.is_alive() + + +def invoke(*arguments): + return CliRunner().invoke(create_cli(), list(arguments)) + + +@pytest.mark.parametrize("selection", ["explicit", "minimax_env", "mavis_env"]) +def test_native_discovery_repeat_install_and_readonly_doctor(host, monkeypatch, selection): + root, endpoint = host + data = root / "MiniMax 原生 profile" + options = ["--minimax-data-dir", str(data)] if selection == "explicit" else [] + if selection != "explicit": + monkeypatch.setenv("MINIMAX_DATA_DIR" if selection == "minimax_env" else "MAVIS_DATA_DIR", str(data)) + arguments = [ + "setup", + "minimax", + "--source", + str(ROOT), + "--minimax-command", + COMMAND, + "--server-url", + endpoint, + "--json", + *options, + ] + for _ in range(2): + result = invoke(*arguments) + assert result.exit_code == 0, result.output + row = json.loads(result.output)["hosts"]["minimax"] + assert row["checks"]["native"]["status"] == "ok" + assert row["checks"]["server_access"]["status"] == "ok" + private = data / "mcp.json" + assert "acceptance-only-token" in private.read_text() + before = {path: path.read_bytes() for path in data.rglob("*") if path.is_file()} + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 0, result.output + assert {path: path.read_bytes() for path in data.rglob("*") if path.is_file()} == before + assert "acceptance-only-token" not in result.output + environment = dict(os.environ, MINIMAX_DATA_DIR=str(data)) + disabled = subprocess.run( + [COMMAND, "plugin", "disable", "powercontext@local", "--json"], + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert disabled.returncode == 0, disabled.stderr + before = {path: path.read_bytes() for path in data.rglob("*") if path.is_file()} + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 1 + assert "disabled" in json.loads(result.output)["hosts"]["minimax"]["checks"]["native"]["detail"] + assert {path: path.read_bytes() for path in data.rglob("*") if path.is_file()} == before + # Repeated setup must respect native disablement, rather than silently enabling it. + result = invoke(*arguments) + assert result.exit_code == 1 + assert "disabled" in json.loads(result.output)["hosts"]["minimax"]["checks"]["native"]["detail"] + + +def test_native_loader_omission_and_private_authorization_failure(host): + root, endpoint = host + data = root / "omitted" + result = invoke( + "setup", + "minimax", + "--source", + str(ROOT), + "--minimax-command", + COMMAND, + "--minimax-data-dir", + str(data), + "--server-url", + endpoint, + "--json", + ) + assert result.exit_code == 0, result.output + private = data / "mcp.json" + config = json.loads(private.read_text()) + config["mcpServers"]["powercontext"]["headers"]["Authorization"] = "Bearer wrong-acceptance-token" + private.write_text(json.dumps(config)) + manifest_path = data / "plugins/powercontext/.minimax-plugin/plugin.json" + manifest = json.loads(manifest_path.read_bytes()) + # Local resource checks pass, but the native loader rejects a known field's invalid type. + manifest["displayName"] = 123 + manifest_path.write_text(json.dumps(manifest)) + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 1 + checks = json.loads(result.output)["hosts"]["minimax"]["checks"] + assert checks["native"]["status"] == "failed" + assert "omitted" in checks["native"]["detail"] + assert checks["server_access"]["status"] == "failed" + assert "401" in checks["server_access"]["detail"] + assert "wrong-acceptance-token" not in result.output + + +@pytest.mark.parametrize("host", [False], indirect=True, ids=["anonymous-server"]) +@pytest.mark.parametrize("authorization", [False, True], ids=["without-authorization", "with-authorization"]) +def test_native_setup_preserves_disabled_private_override_and_probes_bundle(host, authorization): + root, endpoint = host + source = root / "bundle" + shutil.copytree(ROOT / "integrations/minimax/plugins/powercontext", source) + bundled_path = source / "powercontext.mcp.json" + bundled = json.loads(bundled_path.read_bytes()) + bundled["mcpServers"]["powercontext"]["url"] = endpoint + "/mcp" + bundled_path.write_text(json.dumps(bundled)) + data = root / "disabled-private" + data.mkdir() + private = data / "mcp.json" + entry = {"type": "streamable-http", "url": "https://inactive.example/mcp", "enabled": False} + if authorization: + entry["headers"] = {"Authorization": "Bearer inactive-private-token"} + original = ( + json.dumps({"note": "保留格式和禁用连接", "mcpServers": {"powercontext": entry}}, ensure_ascii=False, indent=4) + + "\n" + ).encode() + private.write_bytes(original) + for _ in range(2): + result = invoke( + "setup", + "minimax", + "--source", + str(source), + "--minimax-command", + COMMAND, + "--minimax-data-dir", + str(data), + "--json", + ) + assert result.exit_code == 0, result.output + row = json.loads(result.output)["hosts"]["minimax"] + assert row["checks"]["native"]["status"] == "ok" + assert row["checks"]["server_access"]["status"] == "ok" + assert endpoint in row["checks"]["server_liveness"]["detail"] + assert row["result"]["private_mcp_state"] == "disabled" + assert private.read_bytes() == original + assert "inactive-private-token" not in result.output + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 0, result.output + assert private.read_bytes() == original + assert endpoint in json.loads(result.output)["hosts"]["minimax"]["checks"]["server_liveness"]["detail"] + + +def _objects(value): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _objects(child) + elif isinstance(value, list): + for child in value: + yield from _objects(child) + elif isinstance(value, str): + # Native results can contain prose or JSON followed by structured content. + for block in value.split("\n[MCP structured content]\n"): + try: + decoded, _ = json.JSONDecoder().raw_decode(block.lstrip()) + except ValueError: + continue + yield from _objects(decoded) + + +class ModelGateway(ThreadingHTTPServer): + def __init__(self, *, allow_multiple_scope_tools=False): + super().__init__(("127.0.0.1", 0), ModelHandler) + self.allow_multiple_scope_tools = allow_multiple_scope_tools + self.requests: list[dict[str, Any]] = [] + self.request_observations: list[dict[str, Any]] = [] + self.invoked_tools: list[str] = [] + self.scope_call_ids: set[str] = set() + self.errors: list[str] = [] + + def scope_tool(self, names): + matches = sorted({name for name in names if name.endswith("list_scopes")}) + if not matches or (len(matches) != 1 and not self.allow_multiple_scope_tools): + expected = "at least one" if self.allow_multiple_scope_tools else "one isolated" + self.errors.append(f"Expected {expected} list_scopes tool, discovered {matches!r}; tools={names!r}") + return None + return matches[0] + + +class ModelHandler(BaseHTTPRequestHandler): + server: ModelGateway + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - match the HTTP handler interface. + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + # Provider negotiation also probes formats this controlled endpoint does not implement. + if "messages" not in body: + self.send_error(404, "Only OpenAI chat completions are supported") + return + self.server.request_observations.append({ + "received_at_ms": round(time.monotonic() * 1000, 2), + "has_tool_catalog": "tools" in body, + "tools": [tool["function"]["name"] for tool in body.get("tools", [])], + }) + self.server.requests.append(body) + messages = body["messages"] + tool_results = [message for message in messages if message["role"] == "tool"] + names = [tool["function"]["name"] for tool in body.get("tools", [])] + selected = None + arguments = {} + call_id = f"call-{len(tool_results)}" + if not tool_results and "tool_search" in names: + selected = "tool_search" + arguments = {"query": "list_scopes", "top_k": 20} + elif tool_results and len(tool_results) == 1 and "mcp_invoke" in names: + match = self.server.scope_tool([ + item["name"] for item in _objects(tool_results[0]["content"]) if isinstance(item.get("name"), str) + ]) + if match: + selected = "mcp_invoke" + arguments = {"tool_name": match, "arguments": {"limit": 10}} + self.server.invoked_tools.append(match) + self.server.scope_call_ids.add(call_id) + elif not tool_results and "tools" in body: + selected = self.server.scope_tool(names) + arguments = {"limit": 10} + if selected: + self.server.invoked_tools.append(selected) + self.server.scope_call_ids.add(call_id) + delta: dict[str, Any] = { + "content": "MINIMAX_MCP_ACCEPTANCE_FAILED" if self.server.errors else "MINIMAX_MCP_ACCEPTANCE_OK" + } + finish = "stop" + if selected: + delta = { + "tool_calls": [ + { + "index": 0, + "id": call_id, + "type": "function", + "function": {"name": selected, "arguments": json.dumps(arguments)}, + } + ] + } + finish = "tool_calls" + self.send_response(200) + if body.get("stream"): + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + for content in [ + { + "id": "acceptance", + "object": "chat.completion.chunk", + "created": 0, + "model": "fixture", + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + }, + { + "id": "acceptance", + "object": "chat.completion.chunk", + "created": 0, + "model": "fixture", + "choices": [{"index": 0, "delta": delta, "finish_reason": None}], + }, + { + "id": "acceptance", + "object": "chat.completion.chunk", + "created": 0, + "model": "fixture", + "choices": [{"index": 0, "delta": {}, "finish_reason": finish}], + "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, + }, + ]: + self.wfile.write(f"data: {json.dumps(content)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + else: + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({ + "id": "acceptance", + "object": "chat.completion", + "model": "fixture", + "choices": [{"index": 0, "message": {"role": "assistant", **delta}, "finish_reason": finish}], + "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, + }).encode() + ) + + +@pytest.mark.parametrize("route", ["private-mcp", "plugin-mcp", "installed-default"]) +@pytest.mark.parametrize("startup", ["cold", "preflight"]) +def test_real_host_invokes_powercontext_mcp_with_controlled_inference(host, route, startup, mcp_trace): + root, endpoint = host + data = root / "mcp-tool" + result = invoke( + "setup", + "minimax", + "--source", + str(ROOT), + "--minimax-command", + COMMAND, + "--minimax-data-dir", + str(data), + "--server-url", + endpoint, + "--json", + ) + assert result.exit_code == 0, result.output + environment = dict(os.environ, MINIMAX_DATA_DIR=str(data), MCODE_PROVIDER_API_KEY="synthetic-model-token") + private_path = data / "mcp.json" + private = json.loads(private_path.read_bytes()) + private_entry = private["mcpServers"]["powercontext"] + bundle = json.loads((data / "plugins/powercontext/powercontext.mcp.json").read_bytes()) + bundle_entry = bundle["mcpServers"]["powercontext"] + assert private_entry["url"] == bundle_entry["url"] == endpoint + "/mcp" + assert private_entry["headers"] == bundle_entry["headers"] == {"Authorization": "Bearer acceptance-only-token"} + if route == "plugin-mcp": + del private["mcpServers"]["powercontext"] + private_path.write_text(json.dumps(private), encoding="utf-8") + assert "powercontext" not in json.loads(private_path.read_bytes())["mcpServers"] + elif route == "private-mcp": + disabled = subprocess.run( + [COMMAND, "plugin", "disable", "powercontext@local", "--json"], + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert disabled.returncode == 0, disabled.stdout + disabled.stderr + if route != "plugin-mcp": + assert private_entry.get("enabled", True) is True + discovered = subprocess.run( + [COMMAND, "plugin", "list", "--json"], + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert discovered.returncode == 0, discovered.stdout + discovered.stderr + plugins = [ + item + for item in json.loads(discovered.stdout)["installed"] + if item["name"] == "powercontext" and item["marketplaceName"] == "local" + ] + assert len(plugins) == 1 and plugins[0]["enabled"] is (route != "private-mcp"), discovered.stdout + marker = "MiniMax native MCP scope evidence" + with httpx.Client( + base_url=endpoint, headers={"Authorization": "Bearer acceptance-only-token"}, timeout=10 + ) as client: + response = client.post( + "/v1/scopes", + json={"title": marker, "summary": "Isolated native MCP acceptance", "idempotency_key": "minimax-scope"}, + ) + assert response.status_code == 201, response.text + scope_id = response.json()["scope_id"] + + async def discover_server_tools(): + transport = StreamableHttpTransport(bundle_entry["url"], headers=bundle_entry["headers"]) + async with Client(transport, timeout=10, init_timeout=10) as client: + return sorted(tool.name for tool in await client.list_tools()) + + preflight: dict[str, Any] = {"performed": startup == "preflight"} + if startup == "preflight": + started = time.monotonic() + server_tools = asyncio.run(discover_server_tools()) + preflight.update(tools=server_tools, elapsed_ms=round((time.monotonic() - started) * 1000, 2)) + assert "list_scopes" in server_tools, preflight + diagnostics = root / "native-diagnostics" + evidence = root / "acceptance-evidence.json" + gateway = ModelGateway(allow_multiple_scope_tools=route == "installed-default") + scope_result_matched = False + thread = threading.Thread(target=gateway.serve_forever, daemon=True) + thread.start() + try: + added = subprocess.run( + [ + COMMAND, + "provider", + "add", + "--name", + "acceptance", + "--base-url", + f"http://127.0.0.1:{gateway.server_port}/v1", + "--api-format", + "openai-completions", + "--model", + "fixture", + "--use", + ], + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert added.returncode == 0, added.stderr + mcp_trace.clear() + executed = subprocess.run( + [ + COMMAND, + "exec", + "List PowerContext scopes using MCP; return the scope titles.", + "--permission", + "full", + "--max-steps", + "5", + "--timeout", + "40s", + "--output-format", + "json", + "--cwd", + str(root), + "--diagnostics-dir", + str(diagnostics), + ], + env=environment, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert executed.returncode == 0, executed.stdout + executed.stderr + assert not gateway.errors, { + "errors": gateway.errors, + "evidence": str(evidence), + "native_stderr": executed.stderr, + } + observed = [ + message + for request in gateway.requests + for message in request.get("messages", []) + if message["role"] == "tool" and message.get("tool_call_id") in gateway.scope_call_ids + ] + scope_result_matched = any( + item.get("scope_id") == scope_id and item.get("title") == marker + for message in observed + for item in _objects(message["content"]) + ) + assert scope_result_matched, { + "stdout": executed.stdout, + "stderr": executed.stderr, + "tools": gateway.invoked_tools, + "results": observed, + "evidence": str(evidence), + } + assert any(event.get("response_has_list_scopes") is True for event in mcp_trace), str(evidence) + assert len(set(gateway.invoked_tools)) == 1, gateway.invoked_tools + if route != "private-mcp": + assert any("powercontext-project-context" in json.dumps(request) for request in gateway.requests) + finally: + gateway.shutdown() + gateway.server_close() + thread.join(timeout=5) + evidence.write_text( + json.dumps( + { + "route": route, + "startup": startup, + "server_preflight": preflight, + "native_mcp_requests": mcp_trace, + "model_requests": gateway.request_observations, + "invoked_tools": gateway.invoked_tools, + "scope_result_matched": scope_result_matched, + "errors": gateway.errors, + "native_diagnostics": str(diagnostics), + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def test_real_doctor_does_not_initialize_an_uninstalled_native_target(host): + root, _ = host + data = root / "uninstalled native profile" + result = invoke("doctor", "minimax", "--minimax-data-dir", str(data), "--minimax-command", COMMAND, "--json") + assert result.exit_code == 1 + assert not data.exists() diff --git a/tests/install_support.py b/tests/install_support.py index 3d7989533a..7efe6fb066 100644 --- a/tests/install_support.py +++ b/tests/install_support.py @@ -15,9 +15,11 @@ """Native-operation substitutes for CLI routing tests; the shared driver still runs.""" +from contextlib import nullcontext +from pathlib import Path from types import SimpleNamespace -from powercontext.cli.installation import installers +from powercontext.cli.installation import PluginSource, installers def stub_installer(monkeypatch, host, callback): @@ -26,14 +28,19 @@ def stub_installer(monkeypatch, host, callback): installer_type = type(installers()[host]()) prepare = transport.prepare_setup_transport connection = None + if host == "minimax": + package = Path(__file__).parents[1] / "integrations/minimax/plugins/powercontext" + monkeypatch.setattr(PluginSource, "package", lambda self, selected_host: nullcontext(package)) def prepare_connection(*args, **kwargs): nonlocal connection connection = prepare(*args, **kwargs) return connection - def install(self, source): + def install(self, source, **install_options): assert connection is not None + if host == "minimax": + assert install_options["connection"] is connection options = vars(self) | { "host": host, "source": source.location, diff --git a/tests/test_cli_minimax.py b/tests/test_cli_minimax.py new file mode 100644 index 0000000000..b814f52e94 --- /dev/null +++ b/tests/test_cli_minimax.py @@ -0,0 +1,941 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MiniMax setup protects native packages and reports actual discovery outcomes.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from unittest.mock import Mock + +import pytest +from typer.testing import CliRunner + +from powercontext.cli import installation, minimax, system +from powercontext.cli.app import create_cli +from powercontext.cli.system import Diagnostic, DiagnosticStatus + +ROOT = Path(__file__).parents[1] +PACKAGE = ROOT / "integrations/minimax/plugins/powercontext" + + +@pytest.fixture +def native(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(minimax.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "pc")) + for name in ("MINIMAX_DATA_DIR", "MAVIS_DATA_DIR", "MAVIS_PROFILE", "POWERCONTEXT_MINIMAX_AUTHORIZATION"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(minimax.shutil, "which", lambda _name: None) + monkeypatch.setattr( + minimax.MiniMaxInstaller, + "server_checks", + lambda _self: {"server_access": Diagnostic(DiagnosticStatus.OK, "fixture Server access")}, + ) + command = tmp_path / "mcode" + command.touch() + command.chmod(0o700) + payload = { + "installed": [ + { + "name": "powercontext", + "marketplaceName": "local", + "installed": True, + "enabled": True, + "version": "0.2.0", + "capabilities": {"skillCount": 1, "mcpServerCount": 1}, + } + ], + "available": [], + } + run = Mock(side_effect=lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, json.dumps(payload), "")) + monkeypatch.setattr(minimax.subprocess, "run", run) + return tmp_path, command, payload, run + + +def invoke(*arguments, stdin_text=None): + return CliRunner().invoke(create_cli(), list(arguments), input=stdin_text) + + +def setup(native, *arguments, source=ROOT): + root, command, *_ = native + return invoke( + "setup", + "minimax", + "--source", + str(source), + "--minimax-data-dir", + str(root / "宿主 profile"), + "--minimax-command", + str(command), + "--json", + *arguments, + ) + + +def installed(native): + return native[0] / "宿主 profile/plugins/powercontext" + + +@pytest.mark.parametrize( + "newline,indent", + [(None, 2), ("\n", 2), ("\r\n", 2), ("\n", 4)], + ids=["repository", "lf", "crlf", "four-space-indent"], +) +def test_complete_package_and_saved_target_are_reused_by_readonly_doctor(native, newline, indent): + package = PACKAGE + source_root = ROOT + if newline is not None: + package = native[0] / "source" + shutil.copytree(PACKAGE, package) + mcp = package / "powercontext.mcp.json" + content = json.dumps(json.loads(mcp.read_bytes()), ensure_ascii=False, indent=indent) + "\n" + mcp.write_bytes(content.replace("\n", newline).encode("utf-8")) + source_root = package + result = setup(native, source=source_root) + assert result.exit_code == 0, result.output + plugin = installed(native) + for source in package.rglob("*"): + if source.is_file(): + relative = source.relative_to(package) + target = plugin / relative + if relative.as_posix() == "powercontext.mcp.json": + # Setup renders this configuration; source whitespace is not part of its contract. + assert json.loads(target.read_bytes()) == json.loads(source.read_bytes()) + else: + assert target.read_bytes() == source.read_bytes() + before = {path: path.read_bytes() for path in native[0].rglob("*") if path.is_file()} + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 0, result.output + assert {path: path.read_bytes() for path in native[0].rglob("*") if path.is_file()} == before + + +@pytest.mark.parametrize("primary,fallback", [("minimax", "mavis"), (" ", "mavis"), ("", "")]) +def test_directory_environment_precedence(native, monkeypatch, primary, fallback): + root, command, *_ = native + monkeypatch.setenv("MINIMAX_DATA_DIR", str(root / primary) if primary.strip() else primary) + monkeypatch.setenv("MAVIS_DATA_DIR", str(root / fallback) if fallback else "") + if primary.strip() or fallback: + result = invoke("setup", "minimax", "--source", str(ROOT), "--minimax-command", str(command), "--json") + else: + result = invoke("setup", "minimax", "--source", str(ROOT), "--json") + target = root / (primary if primary.strip() else fallback or ".minimax") + assert (target / "plugins/powercontext/.minimax-plugin/plugin.json").is_file(), result.output + assert result.exit_code == (0 if primary.strip() or fallback else 1) + + +def test_default_directory_uses_path_home_instead_of_environment_home(native, monkeypatch): + root, *_ = native + monkeypatch.setenv("HOME", str(root / "different-home")) + monkeypatch.setenv("USERPROFILE", str(root / "different-profile")) + result = invoke("setup", "minimax", "--source", str(ROOT), "--json") + assert (root / ".minimax/plugins/powercontext/.minimax-plugin/plugin.json").is_file(), result.output + assert not (root / "different-home").exists() + assert not (root / "different-profile").exists() + + +def test_explicit_directory_overrides_environment(native, monkeypatch): + root, *_ = native + monkeypatch.setenv("MINIMAX_DATA_DIR", str(root / "other")) + assert setup(native).exit_code == 0 + assert installed(native).is_dir() + assert not (root / "other").exists() + + +def test_native_marketplace_selects_profile_directory(native): + root, command, _, run = native + profile = root / "native-profile" + original = run.side_effect + run.side_effect = lambda args, **kwargs: ( + subprocess.CompletedProcess([], 0, json.dumps([{"name": "local", "path": str(profile / "plugins")}]), "") + if "marketplace" in args + else original(args, **kwargs) + ) + result = invoke("setup", "minimax", "--source", str(ROOT), "--minimax-command", str(command), "--json") + assert result.exit_code == 0, result.output + assert (profile / "plugins/powercontext/.minimax-plugin/plugin.json").is_file() + + +def test_reinstall_and_upgrade_preserve_private_configuration_and_user_files(native, monkeypatch): + root, *_ = native + private = root / "宿主 profile/mcp.json" + private.parent.mkdir() + value = { + "custom": "keep", + "mcpServers": { + "other": {"command": "other"}, + "powercontext": { + "type": "streamable-http", + "url": "https://memory.example/mcp", + "headers": {"Authorization": "Bearer private-token", "X-User": "keep"}, + "enabled": True, + "custom": "keep", + }, + }, + } + private.write_text(json.dumps(value)) + assert setup(native).exit_code == 0 + plugin = installed(native) + (plugin / "user.txt").write_text("keep") + other = plugin.parent / "other" + other.mkdir() + (other / "keep.txt").write_text("other plugin") + source = root / "incoming" + shutil.copytree(PACKAGE, source) + (source / "README.md").write_text("Updated package documentation") + result = setup(native, source=source) + assert result.exit_code == 0, result.output + assert (plugin / "README.md").read_text() == "Updated package documentation" + assert (plugin / "user.txt").read_text() == "keep" + assert (other / "keep.txt").read_text() == "other plugin" + assert json.loads(private.read_text()) == value + assert "private-token" not in result.output + if os.name != "nt": + assert private.stat().st_mode & 0o077 == 0 + + +def test_modified_managed_file_blocks_upgrade_before_writes(native): + assert setup(native).exit_code == 0 + plugin = installed(native) + (plugin / "README.md").write_text("User edit") + result = setup(native) + assert result.exit_code == 1 + assert "conflict" in result.output + assert (plugin / "README.md").read_text() == "User edit" + + +def test_matching_manual_installation_is_adopted_but_different_one_is_rejected(native): + plugin = installed(native) + shutil.copytree(PACKAGE, plugin) + (plugin / "user.txt").write_text("keep") + assert setup(native).exit_code == 0 + assert (plugin / "user.txt").read_text() == "keep" + + +def test_unmanaged_different_installation_is_preserved(native): + plugin = installed(native) + shutil.copytree(PACKAGE, plugin) + (plugin / "README.md").write_text("private customization") + result = setup(native) + assert result.exit_code == 1 + assert "Unmanaged" in result.output + assert (plugin / "README.md").read_text() == "private customization" + assert not (plugin.parent.parent / "mcp.json").exists() + + +@pytest.mark.parametrize( + "missing", + [ + "LICENSE", + ".minimax-plugin/plugin.json", + "icon.png", + "powercontext.mcp.json", + "skills/powercontext-project-context/references/work-handoff.md", + ], +) +def test_incomplete_source_leaves_prior_installation_usable(native, missing): + assert setup(native).exit_code == 0 + source = native[0] / "incomplete" + shutil.copytree(PACKAGE, source) + (source / missing).unlink() + before = {path: path.read_bytes() for path in installed(native).rglob("*") if path.is_file()} + result = setup(native, source=source) + assert result.exit_code == 1 + assert {path: path.read_bytes() for path in installed(native).rglob("*") if path.is_file()} == before + + +def test_failed_package_publication_restores_previous_package_and_record(native, monkeypatch): + assert setup(native).exit_code == 0 + source = native[0] / "upgrade" + shutil.copytree(PACKAGE, source) + (source / "README.md").write_text("new version") + before = { + path: path.read_bytes() for path in native[0].rglob("*") if path.is_file() and "installations" in path.parts + } + write = installation.write_bytes + + def fail_record(path, content): + if "installations" in path.parts: + raise OSError("record write failed") # noqa: TRY003 - simulated disk failure. + write(path, content) + + monkeypatch.setattr(installation, "write_bytes", fail_record) + result = setup(native, source=source) + assert result.exit_code == 1 + assert (installed(native) / "README.md").read_bytes() == (PACKAGE / "README.md").read_bytes() + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize( + "failure", ["omitted", "disabled", "skills", "mcp", "source", "version", "invalid", "exit", "timeout"] +) +def test_native_failure_does_not_report_files_as_full_success(native, failure): + _, _, payload, run = native + plugin = payload["installed"][0] + if failure == "omitted": + payload["installed"] = [] + elif failure == "disabled": + plugin["enabled"] = False + elif failure in {"skills", "mcp"}: + plugin["capabilities"]["skillCount" if failure == "skills" else "mcpServerCount"] = 0 + elif failure == "source": + plugin["marketplaceName"] = "official" + elif failure == "version": + plugin["version"] = "wrong" + elif failure == "invalid": + run.side_effect = None + run.return_value = subprocess.CompletedProcess([], 0, "not json", "private-token") + elif failure == "exit": + run.side_effect = None + run.return_value = subprocess.CompletedProcess([], 1, "", "private-token") + else: + run.side_effect = subprocess.TimeoutExpired("mcode", 30) + result = setup(native) + assert result.exit_code == 1 + row = json.loads(result.output)["hosts"]["minimax"] + assert row["setup"] == "complete" + assert row["checks"]["native"]["status"] == "failed" + assert row["checks"]["package"]["status"] == "ok" + assert "private-token" not in result.output + + +def test_missing_cli_reports_copied_files_and_verification_guidance(native): + result = invoke( + "setup", "minimax", "--source", str(ROOT), "--minimax-data-dir", str(native[0] / "no-cli"), "--json" + ) + assert result.exit_code == 1 + row = json.loads(result.output)["hosts"]["minimax"] + assert row["setup"] == "complete" + assert row["checks"]["native"]["status"] == "degraded" + assert "--minimax-command" in row["checks"]["native"]["detail"] + + +def test_invalid_explicit_launcher_fails_before_install(native): + result = invoke( + "setup", + "minimax", + "--source", + str(ROOT), + "--minimax-data-dir", + str(native[0] / "absent"), + "--minimax-command", + str(native[0] / "missing"), + "--json", + ) + assert result.exit_code == 1 + assert not (native[0] / "absent").exists() + + +def test_path_launcher_is_reusable_after_the_working_directory_changes(native, monkeypatch): + from powercontext.client.transport_policy import load_client_settings + + root, command, _, run = native + launcher = root / "bin/mcode" + launcher.parent.mkdir() + shutil.copy2(command, launcher) + monkeypatch.setattr(minimax.shutil, "which", lambda _name: "bin/mcode") + original = run.side_effect + + def run_from_directory(arguments, **kwargs): + selected = Path(arguments[0]) + if not selected.is_absolute(): + selected = (kwargs.get("cwd") or Path.cwd()) / selected + if not selected.is_file(): + raise FileNotFoundError(selected) + return original(arguments, **kwargs) + + run.side_effect = run_from_directory + result = invoke( + "setup", "minimax", "--source", str(ROOT), "--minimax-data-dir", str(root / "宿主 profile"), "--json" + ) + assert result.exit_code == 0, result.output + assert load_client_settings("minimax")["minimax_command"] == str(launcher) + elsewhere = root / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 0, result.output + + +def test_malformed_private_json_fails_without_install_or_secret_output(native): + path = native[0] / "宿主 profile/mcp.json" + path.parent.mkdir() + path.write_text('{"private-token":') + result = setup(native) + assert result.exit_code == 1 + assert "private-token" not in result.output + assert not installed(native).exists() + assert path.read_text() == '{"private-token":' + + +def test_setup_credentials_are_private_and_shared_by_both_native_connections(native, monkeypatch): + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "secret-value") + result = setup(native) + assert result.exit_code == 0, result.output + private = native[0] / "宿主 profile/mcp.json" + assert ( + json.loads(private.read_text())["mcpServers"]["powercontext"]["headers"]["Authorization"] + == "Bearer secret-value" + ) + assert "secret-value" not in result.output + for path in (native[0] / "pc").rglob("*"): + if path.is_file(): + assert b"secret-value" not in path.read_bytes() + bundle = installed(native) / "powercontext.mcp.json" + assert json.loads(bundle.read_bytes())["mcpServers"]["powercontext"]["headers"] == { + "Authorization": "Bearer secret-value" + } + assert b"secret-value" not in (PACKAGE / "powercontext.mcp.json").read_bytes() + if os.name != "nt": + assert bundle.stat().st_mode & 0o077 == 0 + + +def test_interactive_selection_contains_minimax(native, monkeypatch): + monkeypatch.setattr("powercontext.cli.hosts.stdin_is_tty", lambda: True) + root, command, *_ = native + result = invoke( + "setup", + "--source", + str(ROOT), + "--minimax-data-dir", + str(root / "chosen"), + "--minimax-command", + str(command), + stdin_text="minimax\n", + ) + assert result.exit_code == 0, result.output + assert (root / "chosen/plugins/powercontext/.minimax-plugin/plugin.json").is_file() + + +def test_minimax_specific_options_require_selecting_minimax(native): + result = invoke("setup", "codex", "--minimax-data-dir", str(native[0] / "wrong"), "--json") + assert result.exit_code == 2 + assert "requires selecting minimax" in result.output + + +def test_failure_of_another_host_does_not_prevent_minimax_installation(native, monkeypatch): + monkeypatch.setattr( + system.CodexInstaller, "install", Mock(side_effect=installation.SetupError("fixture codex failure")) + ) + root, command, *_ = native + result = invoke( + "setup", + "codex", + "minimax", + "--source", + str(ROOT), + "--minimax-data-dir", + str(root / "mixed"), + "--minimax-command", + str(command), + "--json", + ) + assert result.exit_code == 1 + rows = json.loads(result.output)["hosts"] + assert rows["codex"]["status"] == "failed" + assert rows["minimax"]["ok"] is True + + +def test_upgrade_removes_only_unchanged_obsolete_managed_files(native): + source = native[0] / "package" + shutil.copytree(PACKAGE, source) + (source / "obsolete.txt").write_text("owned") + assert setup(native, source=source).exit_code == 0 + (installed(native) / "custom.txt").write_text("user") + (source / "obsolete.txt").unlink() + result = setup(native, source=source) + assert result.exit_code == 0, result.output + assert not (installed(native) / "obsolete.txt").exists() + assert (installed(native) / "custom.txt").read_text() == "user" + + +def test_new_package_file_cannot_overwrite_user_file(native): + assert setup(native).exit_code == 0 + (installed(native) / "custom.txt").write_text("user") + source = native[0] / "collision" + shutil.copytree(PACKAGE, source) + (source / "custom.txt").write_text("package") + result = setup(native, source=source) + assert result.exit_code == 1 + assert "conflict" in result.output + assert (installed(native) / "custom.txt").read_text() == "user" + + +@pytest.mark.parametrize("render_connection", [False, True], ids=["source-package", "rendered-package"]) +def test_matching_user_file_is_not_adopted_and_deleted_by_later_upgrade(native, render_connection): + from powercontext.cli.transport import SetupTransport + + root, command, *_ = native + source = root / "package" + shutil.copytree(PACKAGE, source) + installer = minimax.MiniMaxInstaller(minimax_data_dir=root / "profile", minimax_command=command) + connection = SetupTransport("minimax", "http://127.0.0.1:8000", False) if render_connection else None + installer.install(installation.PluginSource(str(source)), connection=connection) + user_file = installer.plugin / "user-notes.md" + user_file.write_bytes(b"original user notes\n") + assert "user-notes.md" not in json.loads(installer.record.read_bytes())["files"] + + before_record = installer.record.read_bytes() + before_files = { + path.relative_to(installer.plugin): path.read_bytes() for path in installer.plugin.rglob("*") if path.is_file() + } + incoming_file = source / "user-notes.md" + incoming_file.write_bytes(user_file.read_bytes()) + with pytest.raises(installation.SetupError, match="conflict"): + installer.install(installation.PluginSource(str(source)), connection=connection) + assert installer.record.read_bytes() == before_record + assert { + path.relative_to(installer.plugin): path.read_bytes() for path in installer.plugin.rglob("*") if path.is_file() + } == before_files + + incoming_file.unlink() + installer.install(installation.PluginSource(str(source)), connection=connection) + assert user_file.read_bytes() == b"original user notes\n" + assert "user-notes.md" not in json.loads(installer.record.read_bytes())["files"] + + +@pytest.mark.parametrize("collision", ["directory", "empty-directory", "parent-file"]) +def test_package_path_type_conflicts_leave_user_content_and_installation_unchanged(native, collision): + assert setup(native).exit_code == 0 + plugin = installed(native) + source = native[0] / "collision-package" + shutil.copytree(PACKAGE, source) + if collision == "parent-file": + (plugin / "notes").write_text("user content") + (source / "notes").mkdir() + (source / "notes/item").write_text("package content") + else: + (plugin / "notes").mkdir() + if collision == "directory": + (plugin / "notes/notes").write_text("user content") + (source / "notes").write_text("package content") + before = {p: p.read_bytes() for p in native[0].rglob("*") if p.is_file()} + result = setup(native, source=source) + assert result.exit_code == 1, result.output + assert "conflict" in result.output + assert {p: p.read_bytes() for p in native[0].rglob("*") if p.is_file()} == before + + +def test_rendered_package_does_not_take_ownership_of_user_files(native, monkeypatch): + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "private-secret") + assert setup(native, "--server-url", "https://chosen.example").exit_code == 0 + user_file = installed(native) / "user.txt" + user_file.write_text("first user content") + assert setup(native).exit_code == 0 + user_file.write_text("changed user content") + assert setup(native).exit_code == 0 + assert user_file.read_text() == "changed user content" + bundle = json.loads((installed(native) / "powercontext.mcp.json").read_bytes()) + private = json.loads((installed(native).parent.parent / "mcp.json").read_bytes()) + assert bundle["mcpServers"]["powercontext"]["url"] == "https://chosen.example/mcp" + assert bundle["mcpServers"]["powercontext"]["headers"] == private["mcpServers"]["powercontext"]["headers"] + + +def test_case_insensitive_destination_preserves_differently_cased_user_file(native): + assert setup(native).exit_code == 0 + user_file = installed(native) / "UserNotes" + user_file.write_text("user content") + if not (installed(native) / "usernotes").exists(): + pytest.skip("Requires a case-insensitive filesystem") + source = native[0] / "case-collision-package" + shutil.copytree(PACKAGE, source) + (source / "usernotes").write_text("package content") + result = setup(native, source=source) + assert result.exit_code == 1 + assert "conflict" in result.output + assert user_file.read_text() == "user content" + + +@pytest.mark.parametrize("private_state", ["disabled", "absent"]) +def test_upgrade_preserves_active_plugin_connection_when_private_entry_is_inactive(native, monkeypatch, private_state): + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "active-plugin-secret") + assert setup(native, "--server-url", "https://chosen.example").exit_code == 0 + monkeypatch.delenv("POWERCONTEXT_MINIMAX_AUTHORIZATION") + private = installed(native).parent.parent / "mcp.json" + bundle = installed(native) / "powercontext.mcp.json" + original_bundle = bundle.read_bytes() + original_private = None + if private_state == "disabled": + config = json.loads(private.read_bytes()) + config["mcpServers"]["powercontext"].update( + enabled=False, url="https://dormant.example/mcp", headers={"Authorization": "Bearer dormant-secret"} + ) + original_private = json.dumps(config, indent=4).encode() + private.write_bytes(original_private) + else: + private.unlink() + for _ in range(2): + result = setup(native) + assert result.exit_code == 0, result.output + assert bundle.read_bytes() == original_bundle + if private_state == "disabled": + assert private.read_bytes() == original_private + else: + assert json.loads(private.read_bytes())["mcpServers"]["powercontext"]["headers"] == { + "Authorization": "Bearer active-plugin-secret" + } + + +def test_doctor_does_not_initialize_a_missing_target(native): + root, command, *_ = native + target = root / "uninstalled" + result = invoke("doctor", "minimax", "--minimax-data-dir", str(target), "--minimax-command", str(command), "--json") + assert result.exit_code == 1 + assert not target.exists() + + +def test_doctor_requires_a_target_instead_of_initializing_native_home(native): + root, command, *_ = native + result = invoke("doctor", "minimax", "--minimax-command", str(command), "--json") + assert result.exit_code == 1 + assert "--minimax-data-dir" in result.output + assert not (root / ".minimax").exists() + + +def test_native_diagnostic_initialization_is_confined_to_a_disposable_snapshot(native): + assert setup(native).exit_code == 0 + root, _, payload, run = native + before = {p: p.read_bytes() for p in root.rglob("*") if p.is_file()} + temporary_roots = [] + + def initializing_cli(args, *, env, **kwargs): + data = Path(env["MINIMAX_DATA_DIR"]) + home = Path(env["HOME"]) + assert (data / "plugins/powercontext/.minimax-plugin/plugin.json").is_file() + (data / "config.yaml").write_text("native initialization") + (home / "native-home-state").write_text("native initialization") + temporary_roots.append(home) + return subprocess.CompletedProcess(args, 0, json.dumps(payload), "") + + run.side_effect = initializing_cli + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 0, result.output + assert {p: p.read_bytes() for p in root.rglob("*") if p.is_file()} == before + assert temporary_roots and all(not path.exists() for path in temporary_roots) + + +def test_doctor_reports_divergent_native_connections_without_rewriting_them(native): + assert setup(native).exit_code == 0 + path = installed(native).parent.parent / "mcp.json" + config = json.loads(path.read_bytes()) + config["mcpServers"]["powercontext"]["url"] = "https://other.example/mcp" + path.write_text(json.dumps(config)) + before = path.read_bytes() + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 1 + assert "connections differ" in result.output + assert path.read_bytes() == before + assert invoke("config", "agent", "minimax", "--json").exit_code == 0 + assert invoke("doctor", "minimax", "--json").exit_code == 0 + + +def test_config_agent_rolls_back_both_connections_and_inventory_on_failure(native, monkeypatch): + assert setup(native).exit_code == 0 + before = {p: p.read_bytes() for p in native[0].rglob("*") if p.is_file()} + write = installation.write_bytes + + def fail_inventory(target, content): + if "installations" in target.parts: + raise OSError("record write failure") # noqa: TRY003 - simulated disk failure. + write(target, content) + + monkeypatch.setattr(installation, "write_bytes", fail_inventory) + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "replacement-secret") + result = invoke("config", "agent", "minimax", "--server-url", "https://chosen.example", "--json") + assert result.exit_code == 1 + assert {p: p.read_bytes() for p in native[0].rglob("*") if p.is_file()} == before + assert "replacement-secret" not in result.output + + +def test_configuration_write_failure_preserves_private_and_shared_settings(native, monkeypatch): + assert setup(native).exit_code == 0 + path = installed(native).parent.parent / "mcp.json" + original = path.read_bytes() + settings = native[0] / "client-settings.json" + original_settings = settings.read_bytes() + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "replacement-token") + write = installation.write_bytes + + def fail_private(target, content): + if target == path: + raise OSError("private write failure") # noqa: TRY003 - simulated disk failure. + write(target, content) + + monkeypatch.setattr(installation, "write_bytes", fail_private) + result = setup(native) + assert result.exit_code == 1 + assert path.read_bytes() == original + assert settings.read_bytes() == original_settings + assert "Plugin installation completed; connection configuration failed" in result.output + assert "replacement-token" not in result.output + + +def test_changed_endpoint_does_not_forward_existing_credentials(native, monkeypatch): + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "old-secret") + assert setup(native).exit_code == 0 + monkeypatch.delenv("POWERCONTEXT_MINIMAX_AUTHORIZATION") + path = installed(native).parent.parent / "mcp.json" + original = path.read_bytes() + result = setup(native, "--server-url", "https://different.example") + assert result.exit_code == 1 + assert "provide a new authorization" in result.output + assert path.read_bytes() == original + assert "old-secret" not in result.output + + +def test_remote_ref_selects_package_and_preserves_source_conventions(native, monkeypatch): + def clone_source(source, ref, target): + assert source == "oceanbase/powercontext" + shutil.copytree(PACKAGE, target / "integrations/minimax/plugins/powercontext") + (target / "integrations/minimax/plugins/powercontext/README.md").write_text(f"selected {ref}") + + monkeypatch.setattr("powercontext.cli.git_source.clone_github_source", clone_source) + result = setup(native, "--ref", "matching-tag", source="oceanbase/powercontext") + assert result.exit_code == 0, result.output + assert (installed(native) / "README.md").read_text() == "selected matching-tag" + + +def test_config_agent_keeps_the_saved_native_target_and_credentials(native, monkeypatch): + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "saved-token") + assert setup(native).exit_code == 0 + monkeypatch.delenv("POWERCONTEXT_MINIMAX_AUTHORIZATION") + path = installed(native).parent.parent / "mcp.json" + original = path.read_bytes() + result = invoke("config", "agent", "minimax", "--json") + assert result.exit_code == 0, result.output + assert path.read_bytes() == original + + +def test_setup_rejects_remote_http_without_consent_before_install(native): + result = setup(native, "--server-url", "http://192.0.2.10:8000") + assert result.exit_code == 1 + assert "--allow-insecure-http" in result.output + assert not installed(native).exists() + + +def test_invalid_resource_reference_cannot_escape_package(native): + source = native[0] / "escape" + shutil.copytree(PACKAGE, source) + manifest_path = source / ".minimax-plugin/plugin.json" + manifest = json.loads(manifest_path.read_text()) + manifest["skills"].append("../external.md") + manifest_path.write_text(json.dumps(manifest)) + result = setup(native, source=source) + assert result.exit_code == 1 + assert "inside its package" in result.output + assert not installed(native).exists() + + +@pytest.fixture(params=[False, True], ids=["without-authorization", "with-authorization"]) +def disabled_private(native, request, monkeypatch): + path = native[0] / "宿主 profile/mcp.json" + path.parent.mkdir() + monkeypatch.setenv("MINIMAX_DATA_DIR", str(path.parent)) + entry = { + "type": "streamable-http", + "url": "https://memory.example/mcp", + "enabled": False, + "custom": "保留设置", + "headers": {"X-User": "keep"}, + } + if request.param: + entry["headers"]["Authorization"] = "Bearer disabled-private-token" + config = {"custom": "保持原格式", "mcpServers": {"other": {"command": "other-server"}, "powercontext": entry}} + path.write_bytes((json.dumps(config, ensure_ascii=False, indent=4) + "\n").encode()) + return path + + +@pytest.fixture +def remote_bundle(native): + source = native[0] / "remote-bundle" + shutil.copytree(PACKAGE, source) + mcp = source / "powercontext.mcp.json" + configuration = json.loads(mcp.read_bytes()) + configuration["mcpServers"]["powercontext"]["url"] = "http://192.0.2.10:8000/mcp" + mcp.write_text(json.dumps(configuration)) + return source + + +@pytest.mark.parametrize("consent", ["omitted", "refused", "other-endpoint"]) +def test_bundled_http_requires_consent_before_publication(native, disabled_private, remote_bundle, consent): + from powercontext.client.transport_policy import client_config_file + + if consent == "other-endpoint": + installation.write_json( + client_config_file(), + {"version": 1, "hosts": {"minimax": {"server_url": "http://127.0.0.1:8000", "allow_insecure_http": True}}}, + ) + before = {path: path.read_bytes() for path in native[0].rglob("*") if path.is_file()} + arguments = ["--no-allow-insecure-http"] if consent == "refused" else [] + result = setup(native, *arguments, source=remote_bundle) + assert result.exit_code == 1, result.output + assert "--allow-insecure-http" in result.output + assert not installed(native).exists() + assert {path: path.read_bytes() for path in native[0].rglob("*") if path.is_file()} == before + + +def test_bundled_http_consent_is_bound_to_the_active_endpoint(native, disabled_private, remote_bundle): + from powercontext.cli.native_transport import resolve_host_transport + from powercontext.client.transport_policy import load_client_settings + + original = disabled_private.read_bytes() + result = setup(native, "--allow-insecure-http", source=remote_bundle) + assert result.exit_code == 0, result.output + settings = load_client_settings("minimax") + assert settings["server_url"] == "http://192.0.2.10:8000" + assert settings["allow_insecure_http"] is True + assert resolve_host_transport("minimax") == ("http://192.0.2.10:8000", True) + for operation in ("setup", "config"): + result = setup(native) if operation == "setup" else invoke("config", "agent", "minimax", "--json") + assert result.exit_code == 0, result.output + assert disabled_private.read_bytes() == original + assert resolve_host_transport("minimax") == ("http://192.0.2.10:8000", True) + + +def test_explicit_endpoint_overrides_incoming_bundle_without_enabling_private_entry( + native, disabled_private, remote_bundle, monkeypatch +): + from powercontext.cli.native_transport import resolve_host_transport + + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "replacement-token") + result = setup(native, "--server-url", "https://selected.example", source=remote_bundle) + assert result.exit_code == 0, result.output + private = json.loads(disabled_private.read_bytes())["mcpServers"]["powercontext"] + assert private["url"] == "https://selected.example/mcp" + assert private["enabled"] is False + assert resolve_host_transport("minimax") == ("https://selected.example", False) + + +@pytest.mark.parametrize("operation", ["setup", "config"]) +def test_disabled_private_entry_survives_repeated_configuration_byte_for_byte(native, disabled_private, operation): + original = disabled_private.read_bytes() + for _ in range(2): + result = setup(native) if operation == "setup" else invoke("config", "agent", "minimax", "--json") + assert result.exit_code == 0, result.output + assert disabled_private.read_bytes() == original + assert "endpoint changed" not in result.output + assert "disabled-private-token" not in result.output + + +def test_disabled_private_entry_accepts_credential_rotation_without_moving_endpoint( + native, disabled_private, monkeypatch +): + original = json.loads(disabled_private.read_bytes()) + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "rotated-private-token") + result = setup(native) + assert result.exit_code == 0, result.output + expected = original + expected["mcpServers"]["powercontext"]["headers"]["Authorization"] = "Bearer rotated-private-token" + assert json.loads(disabled_private.read_bytes()) == expected + assert "rotated-private-token" not in result.output + + +def test_explicit_same_endpoint_keeps_disabled_private_configuration(native, disabled_private): + original = disabled_private.read_bytes() + result = setup(native, "--server-url", "https://memory.example") + assert result.exit_code == 0, result.output + assert disabled_private.read_bytes() == original + + +def test_explicit_changed_endpoint_protects_disabled_private_credentials(native, disabled_private): + original = disabled_private.read_bytes() + previous = json.loads(original)["mcpServers"]["powercontext"] + result = setup(native, "--server-url", "https://different.example") + if "Authorization" in previous["headers"]: + assert result.exit_code == 1 + assert "provide a new authorization" in result.output + assert disabled_private.read_bytes() == original + else: + assert result.exit_code == 0, result.output + updated = json.loads(disabled_private.read_bytes())["mcpServers"]["powercontext"] + assert updated == {**previous, "url": "https://different.example/mcp"} + + +@pytest.mark.parametrize("operation", ["setup", "config"]) +def test_explicit_endpoint_and_credential_update_does_not_enable_private_entry( + native, disabled_private, monkeypatch, operation +): + original = json.loads(disabled_private.read_bytes()) + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "replacement-private-token") + if operation == "setup": + result = setup(native, "--server-url", "https://different.example") + else: + result = invoke("config", "agent", "minimax", "--server-url", "https://different.example", "--json") + assert result.exit_code == 0, result.output + expected = original + expected["mcpServers"]["powercontext"]["url"] = "https://different.example/mcp" + expected["mcpServers"]["powercontext"]["headers"]["Authorization"] = "Bearer replacement-private-token" + assert json.loads(disabled_private.read_bytes()) == expected + assert json.loads(result.output)["hosts"]["minimax"]["result"]["private_mcp_state"] == "disabled" + assert "replacement-private-token" not in result.output + + +@pytest.mark.parametrize( + "source,name", + [ + ("process", "POWERCONTEXT_MINIMAX_SERVER_URL"), + ("process", "POWERCONTEXT_CLIENT_SERVER_URL"), + ("file", "POWERCONTEXT_MINIMAX_BASE_URL"), + ("file", "POWERCONTEXT_MINIMAX_ENDPOINT"), + ], +) +def test_declared_environment_endpoint_updates_disabled_private_entry( + native, disabled_private, monkeypatch, source, name +): + original = json.loads(disabled_private.read_bytes()) + monkeypatch.setenv("POWERCONTEXT_MINIMAX_AUTHORIZATION", "replacement-private-token") + if source == "process": + monkeypatch.setenv(name, "https://declared.example") + else: + (native[0] / ".env").write_text(f"{name}=https://declared.example\n") + result = setup(native) + assert result.exit_code == 0, result.output + updated = json.loads(disabled_private.read_bytes())["mcpServers"]["powercontext"] + assert updated["url"] == "https://declared.example/mcp" + assert updated["enabled"] is False + assert updated["custom"] == original["mcpServers"]["powercontext"]["custom"] + + +def test_doctor_resolves_bundled_connection_when_private_entry_is_disabled(native, disabled_private, monkeypatch): + from powercontext.cli.native_transport import resolve_host_transport + + original = disabled_private.read_bytes() + result = setup(native) + assert result.exit_code == 0, result.output + endpoint, _ = resolve_host_transport("minimax") + assert endpoint == "http://127.0.0.1:8000" + result = invoke("doctor", "minimax", "--json") + assert result.exit_code == 0, result.output + assert disabled_private.read_bytes() == original + + +@pytest.mark.parametrize( + "name,value", + [ + ("POWERCONTEXT_SERVER_PUBLIC_URL", "https://public.example"), + ("POWERCONTEXT_SERVER_HTTP_PORT", "18000"), + ], +) +def test_server_fallback_does_not_retarget_disabled_private_entry(native, disabled_private, monkeypatch, name, value): + original = disabled_private.read_bytes() + monkeypatch.setenv(name, value) + result = setup(native) + assert result.exit_code == 0, result.output + assert disabled_private.read_bytes() == original diff --git a/tests/test_integration_manifest.py b/tests/test_integration_manifest.py index c7008b44ec..2420b81b2f 100644 --- a/tests/test_integration_manifest.py +++ b/tests/test_integration_manifest.py @@ -172,6 +172,7 @@ def test_manifest_v2_declares_pre_compaction_mode_for_every_maintained_agent_hos "workbuddy": "unsupported", "zcode": "unsupported", "kimi-code": "unsupported", + "minimax": "unsupported", } @@ -515,8 +516,8 @@ def test_removing_a_declaration_does_not_hide_its_directory(integration_tree: Pa def test_stale_directory_exclusion_is_reported(integration_tree: Path) -> None: manifest = load_integration_manifest() - (integration_tree / "integrations/minimax").rmdir() - assert integration_directory_errors(manifest, integration_tree) == ("integrations/minimax/: excluded but absent",) + (integration_tree / "integrations/opendal").rmdir() + assert integration_directory_errors(manifest, integration_tree) == ("integrations/opendal/: excluded but absent",) @pytest.mark.parametrize("locale", ["en", "zh"]) diff --git a/tests/test_minimax_model_gateway.py b/tests/test_minimax_model_gateway.py new file mode 100644 index 0000000000..3947d56f3a --- /dev/null +++ b/tests/test_minimax_model_gateway.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Controlled inference must invoke discovered MCP tools without assuming aliases.""" + +import json +import threading + +import httpx +import pytest + +from tests.e2e.test_minimax_host import ModelGateway, _objects, _tool_listing + + +@pytest.fixture +def gateway(request): + server = ModelGateway(allow_multiple_scope_tools=getattr(request, "param", False)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + assert not thread.is_alive() + + +def model_response(gateway, names, *, discovery): + messages = [{"role": "user", "content": "List PowerContext scopes"}] + tools = names + if discovery == "search": + messages.append({ + "role": "tool", + "tool_call_id": "search-result", + "content": json.dumps({"tools": [{"name": name} for name in names]}), + }) + tools = ["mcp_invoke"] + with httpx.Client(trust_env=False, timeout=5) as client: + response = client.post( + f"http://127.0.0.1:{gateway.server_port}/v1/chat/completions", + json={"messages": messages, "tools": [{"function": {"name": name}} for name in tools]}, + ) + assert response.status_code == 200 + return response.json()["choices"][0] + + +@pytest.mark.parametrize("discovery", ["direct", "search"]) +@pytest.mark.parametrize("name", ["mcp__powercontext__list_scopes", "mcp__powercontext_2__list_scopes"]) +def test_gateway_invokes_the_discovered_scope_tool(gateway, discovery, name): + response = model_response(gateway, [name], discovery=discovery) + call = response["message"]["tool_calls"][0] + assert response["finish_reason"] == "tool_calls" + assert call["function"]["name"] == ("mcp_invoke" if discovery == "search" else name) + assert json.loads(call["function"]["arguments"]) == ( + {"tool_name": name, "arguments": {"limit": 10}} if discovery == "search" else {"limit": 10} + ) + assert gateway.invoked_tools == [name] + assert call["id"] in gateway.scope_call_ids + assert not gateway.errors + + +@pytest.mark.parametrize("discovery", ["direct", "search"]) +@pytest.mark.parametrize( + "names", + [[], ["unrelated_tool"], ["mcp__powercontext__list_scopes", "mcp__powercontext_2__list_scopes"]], + ids=["empty", "missing", "ambiguous"], +) +def test_gateway_rejects_missing_or_ambiguous_scope_tools(gateway, discovery, names): + response = model_response(gateway, names, discovery=discovery) + assert "tool_calls" not in response["message"] + assert response["message"]["content"] == "MINIMAX_MCP_ACCEPTANCE_FAILED" + assert not gateway.invoked_tools + assert gateway.errors and "Expected one isolated list_scopes tool" in gateway.errors[0] + + +@pytest.mark.parametrize("gateway", [True], indirect=True) +@pytest.mark.parametrize("discovery", ["direct", "search"]) +def test_installed_default_gateway_invokes_one_discovered_scope_tool(gateway, discovery): + names = ["mcp__powercontext__list_scopes", "mcp__powercontext_2__list_scopes"] + response = model_response(gateway, names, discovery=discovery) + call = response["message"]["tool_calls"][0] + arguments = json.loads(call["function"]["arguments"]) + selected = arguments["tool_name"] if discovery == "search" else call["function"]["name"] + assert selected in names + assert response["finish_reason"] == "tool_calls" + assert arguments == ( + {"tool_name": selected, "arguments": {"limit": 10}} if discovery == "search" else {"limit": 10} + ) + assert gateway.invoked_tools == [selected] + assert call["id"] in gateway.scope_call_ids + assert not gateway.errors + + +@pytest.mark.parametrize("gateway", [True], indirect=True) +@pytest.mark.parametrize("discovery", ["direct", "search"]) +@pytest.mark.parametrize("names", [[], ["unrelated_tool"]], ids=["empty", "missing"]) +def test_installed_default_gateway_still_rejects_missing_scope_tools(gateway, discovery, names): + response = model_response(gateway, names, discovery=discovery) + assert "tool_calls" not in response["message"] + assert response["message"]["content"] == "MINIMAX_MCP_ACCEPTANCE_FAILED" + assert not gateway.invoked_tools + assert gateway.errors and "Expected at least one list_scopes tool" in gateway.errors[0] + + +@pytest.mark.parametrize("prefix", ['{"message": "Scopes listed"}', "Scopes listed"]) +def test_scope_evidence_is_read_from_an_appended_structured_content_block(prefix): + scope = {"scope_id": "seeded-scope", "title": "Seeded scope"} + content = prefix + "\n[MCP structured content]\n" + json.dumps({"scopes": [scope]}) + assert scope in list(_objects(content)) + + +def test_provider_negotiation_without_a_tool_catalog_is_not_an_mcp_discovery_failure(gateway): + with httpx.Client(trust_env=False, timeout=5) as client: + response = client.post( + f"http://127.0.0.1:{gateway.server_port}/v1/chat/completions", + json={"messages": [{"role": "user", "content": "Hi"}]}, + ) + assert response.status_code == 200 + assert not gateway.errors + assert not gateway.invoked_tools + + +@pytest.mark.parametrize("encoding", ["json", "sse", "sse-crlf"]) +def test_native_tools_list_evidence_reads_streamable_http_response_encodings(encoding): + document = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "list_scopes"}]}}) + if encoding != "json": + document = "event: message\ndata: " + document + "\n\n" + if encoding == "sse-crlf": + document = document.replace("\n", "\r\n") + assert _tool_listing(document.encode()) == ["list_scopes"] diff --git a/tests/test_native_transport_diagnostics.py b/tests/test_native_transport_diagnostics.py index 84b016c9ac..e9c4c8ea85 100644 --- a/tests/test_native_transport_diagnostics.py +++ b/tests/test_native_transport_diagnostics.py @@ -182,3 +182,39 @@ def test_missing_native_config_preserves_shared_endpoint_and_consent(tmp_path): def test_openclaw_without_an_endpoint_is_unconfigured_instead_of_loopback(): with pytest.raises(ValueError, match="not configured"): _resolve("openclaw") + + +@pytest.mark.parametrize("private_enabled", [False, True], ids=["plugin", "private"]) +@pytest.mark.parametrize( + "host_consent,common_consent,saved_matches,expected", + [ + (None, None, True, True), + (None, "false", True, False), + (None, "invalid", True, None), + ("false", None, True, False), + ("invalid", None, True, None), + ("true", "false", True, True), + ("false", "true", True, False), + (None, None, False, False), + ], +) +def test_minimax_consent_obeys_current_policy_without_replacing_native_endpoint( + tmp_path, monkeypatch, private_enabled, host_consent, common_consent, saved_matches, expected +): + profile = tmp_path / "minimax" + endpoint = "http://native.example:8000" + entry = {"type": "streamable-http", "url": endpoint + "/mcp"} + _write(profile / "mcp.json", {"mcpServers": {"powercontext": {**entry, "enabled": private_enabled}}}) + _write(profile / "plugins/powercontext/powercontext.mcp.json", {"mcpServers": {"powercontext": entry}}) + monkeypatch.setenv("MINIMAX_DATA_DIR", str(profile)) + _save_shared(tmp_path, "minimax", endpoint if saved_matches else "http://previous.example:8000", True) + monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "https://unrelated.example") + if host_consent is not None: + monkeypatch.setenv("POWERCONTEXT_MINIMAX_ALLOW_INSECURE_HTTP", host_consent) + if common_consent is not None: + monkeypatch.setenv("POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP", common_consent) + if expected is None: + with pytest.raises(ValueError, match="boolean"): + _resolve("minimax") + else: + assert _resolve("minimax") == (endpoint, expected)