diff --git a/.agents/skills/cli-guide/SKILL.md b/.agents/skills/cli-guide/SKILL.md new file mode 100644 index 000000000..67923d76d --- /dev/null +++ b/.agents/skills/cli-guide/SKILL.md @@ -0,0 +1,65 @@ +--- +name: cli-guide +description: Navigate and change the MiniMax Code command tree, interactive TUI, headless exec, ACP adapters, or their in-process runtime connection. Use when implementing or debugging CLI behavior in this repository. +--- + +# CLI Architecture Guide + +Read `docs/architecture.md` and the current command registration before proposing +a change. The standalone entry point lives in `packages/tui`. + +## Entry points + +| Path | Responsibility | +| --- | --- | +| `packages/tui/src/index.ts` | Process bootstrap and runtime environment | +| `packages/tui/src/cli/main.ts` | Command execution and process lifecycle | +| `packages/tui/src/cli/program.ts` | Commander command registration | +| `packages/tui/src/cli/contract.ts` | Interactive and exec option contracts | +| `packages/tui/src/tui/` | Interactive terminal UI | +| `packages/tui/src/headless/` | Exec invocation, output, cancellation and settlement | +| `packages/tui/src/acp/` | ACP protocol adapter | +| `packages/tui/src/runtime/adapter.ts` | Adapter to the in-process CliService | +| `packages/local-runtime-v2/src/local/` | Local product service entry points | +| `packages/protocol/src/local.ts` | Shared CLI data structures | + +The execution path is: + +```text +TUI / exec / ACP + -> CliService + -> local applications + -> session / turn / agent services + -> model providers and tools +``` + +Preserve that in-process boundary. Shared behavior belongs in the relevant +application or service; presentation and transport behavior belong in their +adapters. Trace callers before assuming a fix to one adapter covers the others. + +## Implementing changes + +- For commands and flags, inspect `cli/program.ts`, `cli/contract.ts`, and the + affected command handler together. Check parsing, help, dispatch and errors. +- For interactive behavior, follow the controller and rendering path under + `tui/`. Check session selection, queued input and interruption where affected. +- For exec, follow invocation through output and settlement. Verify stdout, + stderr, exit code and cancellation; help output alone is insufficient. +- For ACP, check protocol updates and the underlying runtime operation together. +- For shared runtime changes, identify all affected TUI, exec and ACP consumers. + +## Verification + +Use [testing-workflow](../testing-workflow/SKILL.md) to select declared tests. +Build the standalone artifact before inspecting its actual help: + +```bash +pnpm build +node dist/cli.js --help +node dist/cli.js exec --help +node dist/cli.js acp --help +``` + +Read current help before documenting flags. Use temporary data directories and +synthetic inputs for behavior checks, following the existing smoke/BYOK tests. +Report offline protocol tests separately from live-provider acceptance. diff --git a/.agents/skills/cross-layer-drift-sweep/SKILL.md b/.agents/skills/cross-layer-drift-sweep/SKILL.md new file mode 100644 index 000000000..0a88b7341 --- /dev/null +++ b/.agents/skills/cross-layer-drift-sweep/SKILL.md @@ -0,0 +1,58 @@ +--- +name: cross-layer-drift-sweep +description: Check completed cross-layer changes for stale fields, defaults, types, contracts or wording across storage, runtime, adapters, TUI and documentation. Use after a rename or contract change with downstream consumers, not for unrelated single-file edits. +--- + +# Cross-Layer Drift Sweep + +Type checking can miss manual mappings, serialized fields and assertions hidden +behind casts. Sweep the narrow data flow affected by the change before reporting +completion. + +## Establish the change + +Record the change axis: type narrowing, field rename, configuration default, +contract evolution or display wording. Name the old and new expressions and the +producer and final observable consumer. + +Draw the relevant flow, for example: + +```text +storage -> runtime writer -> local service -> adapter -> TUI / exec / ACP -> docs +``` + +Only include layers that actually consume the changed value. + +## Sweep + +1. Search both old and new expressions. Include source, comments, tests, + fixtures, snapshots and relevant documentation. Paired searches reveal layers + that adopted only part of a change. +2. Check the likely gaps for the change axis: + + | Axis | Common gaps | + | --- | --- | + | Type narrowing | Runtime writers, filesystem reads with casts, serializers, adapter mappings | + | Field rename | Schema, migrations, inline test DDL, fixtures, mappings, user-visible labels | + | Configuration default | Defaults, thresholds, examples, logs, status output and documentation | + | Contract evolution | Local protocol types, service exports, call sites and compatibility fixtures | + | Display wording | TUI, exec output, logs, screenshots and exact-string assertions | + +3. Fix mismatches within the changed data flow. Explain deliberate compatibility + remnants in an adjacent comment or regression test. Avoid changing unrelated + matches merely because they contain the same token. +4. Verify an assertion at the final observable layer, such as rendered text, + serialized output, a local-service result or a database-backed read. Reuse an + existing assertion when it already covers the behavior; add one when there is + a meaningful regression gap. +5. Run affected tests and type checks using + [testing-workflow](../testing-workflow/SKILL.md). For runtime inputs with + alternate paths or caches, also use + [verify-all-runtime-sinks](../verify-all-runtime-sinks/SKILL.md). + +## Evidence + +Report the axis, data flow, old/new search terms, layers checked, mismatches fixed +and verification results. Explain remaining old-token matches. If the final +consumer could not be exercised, state the gap and the nearest contract evidence +instead of treating a parser-only test as complete validation. diff --git a/.agents/skills/retro/SKILL.md b/.agents/skills/retro/SKILL.md new file mode 100644 index 000000000..bba286109 --- /dev/null +++ b/.agents/skills/retro/SKILL.md @@ -0,0 +1,50 @@ +--- +name: retro +description: Extract evidence-backed lessons from a completed development session and propose focused improvements to repository guidance. Use when the user asks for a retrospective or to capture reusable lessons. +--- + +# Session Retrospective + +Turn concrete failures, retries and workflow gaps into a small set of reusable +improvements. Do not turn one-off preferences into repository-wide rules. + +## Review the evidence + +For each useful lesson, record: + +- **Event:** what happened, with a public-safe file, command, error or commit. +- **Cause:** the demonstrated reason, separated from hypotheses. +- **Lesson:** what a future contributor should do differently. +- **Impact:** safety/data, correctness, efficiency or preference. + +Skip unsupported opinions and one-off circumstances. Combine duplicate lessons +and keep the result focused on the few changes that would prevent recurrence. +Redact credentials, private service details and real user content from evidence. + +## Find the right home + +Read existing guidance before proposing additions: + +| Lesson | Candidate location | +| --- | --- | +| Repository-wide constraint | `AGENTS.md` or `CONTRIBUTING.md` | +| A specific reusable workflow | The relevant `.agents/skills` entry | +| Build, release or source-sync procedure | The relevant document under `docs/` | +| A behavioral defect | A focused code fix and regression test proposal | + +Prefer correcting an existing instruction over introducing a parallel process +or a new notes/proposals directory. Check current source and scripts before +preserving an explanation that might already be stale. + +## Scope and delivery + +If the user asks only for a retrospective, return the findings and proposed +edits. Apply documentation changes when requested, within the established scope. +Do not treat the retrospective as authorization to create issues, send messages, +publish changes or modify personal/agent memory. Write memory only when the user +explicitly asks for that persistence and the environment supports it. + +For applied changes, keep public contributor guidance in English. New files +require publication review and inventory regeneration; content-only changes do +not. Report the lessons, evidence, proposed or applied destinations, checks run +and remaining decisions. diff --git a/.agents/skills/testing-workflow/SKILL.md b/.agents/skills/testing-workflow/SKILL.md new file mode 100644 index 000000000..512bd7c73 --- /dev/null +++ b/.agents/skills/testing-workflow/SKILL.md @@ -0,0 +1,73 @@ +--- +name: testing-workflow +description: Select and report verification for MiniMax Code changes using the repository's declared test suites and CI gates. Use when validating code, skills, documentation, build tooling or source-distribution changes. +--- + +# Testing Workflow + +Start with changed files, then identify the owning runtime, user entry point and +final observable behavior. Read `AGENTS.md`, `CONTRIBUTING.md` and the applicable +steps in `scripts/verify.mjs`. + +## Focused iteration + +Vitest files belong to groups in `test/vitest-suites.json`; aliases and inclusion +come from `vitest.oss.config.mjs`. Select an existing declared file for a focused +run, for example: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/headless-contract.test.ts +``` + +Register new Vitest files in the suite declaration. Do not add hard-coded file +lists to package scripts or the Vitest configuration. Vendored upstream test +suites are outside this distribution's verification. + +| Scope | Repository gate | +| --- | --- | +| Runtime, TUI, providers, plugins and ACP | `pnpm test:capabilities` | +| Headless CLI and offline BYOK behavior | `pnpm test:byok` | +| CLI artifact smoke | `pnpm test:smoke` | +| Public artifact content | `pnpm test:artifact` | +| TUI build-mode contract | `pnpm test:status-contract` | +| Permission facade on macOS/Linux | `pnpm test:policy` | +| Sandbox on macOS | `pnpm test:sandbox` | +| Source-sync, workflow and release tools | `pnpm test:release-tools` | +| Types and standalone build boundary | `pnpm typecheck`, `pnpm build`, `pnpm check:standalone` | +| Published files and generated paths | `pnpm check:source`, `pnpm check:tsconfig` | + +Artifact-dependent tests require a current `pnpm build`. Keep test state and +reports outside the repository, using synthetic data and temporary directories. + +The declared Vitest gate runner serializes test files on Windows to limit +filesystem contention while retaining individual test deadlines. Run the owning +gate for platform acceptance; a direct Vitest invocation uses its default worker +count. Full-runtime smoke children have a 30-second Windows startup budget, +matching ACP; lightweight help and validation checks retain 15 seconds. + +## Delivery verification + +Run `git diff --check` and the relevant individual gates while editing. Review +new or removed files before regenerating `release/public-source.json` with +`node scripts/source-inventory.mjs --write`. + +Before opening a PR, run `pnpm verify` on the reviewed commit with a clean tracked +working tree. Source export reads committed HEAD and rejects uncommitted tracked +changes; report that limitation if only iteration checks are possible. + +Use `pnpm verify --list` to inspect the current platform's gates. Use the `docs` +profile only when every changed path qualifies under `scripts/ci-changes.mjs`. +Skills under `.agents/skills`, unknown paths and inventory changes require the +full profile. The `archive` profile is for source-archive validation, not a way to +bypass the clean-commit export requirement. + +## Manual evidence and reporting + +For CLI behavior, exercise the built `dist/cli.js` and inspect stdout, stderr and +exit code. For terminal presentation, exercise the relevant interaction when +feasible. For provider or service changes, distinguish offline fixtures from a +real endpoint call. + +Report scope, commands and results, manual scenarios, and untested boundaries. +Use PASS/FAIL/BLOCKED for actual checks; mark checks that did not run explicitly. +Local macOS success does not establish Windows/Linux or live-service acceptance. diff --git a/.agents/skills/verify-all-runtime-sinks/SKILL.md b/.agents/skills/verify-all-runtime-sinks/SKILL.md new file mode 100644 index 000000000..0a59f2a31 --- /dev/null +++ b/.agents/skills/verify-all-runtime-sinks/SKILL.md @@ -0,0 +1,56 @@ +--- +name: verify-all-runtime-sinks +description: Verify that runtime-affecting schema, prompt, identity, adapter, cache or rename changes reach every affected consumer. Use before declaring runtime validation complete when alternate entry points or persisted/generated values can retain old behavior. +--- + +# Verify All Runtime Sinks + +Identify where a changed value is finally consumed, beyond its first parser or +happy-path test. This skill defines where to verify; use +[testing-workflow](../testing-workflow/SKILL.md) for commands and gates. + +## Procedure + +1. Name the changed contract, field, prompt, identity or adapter behavior. Identify + the producer and final runtime consumer, and describe the stale behavior that + a partial change would leave behind. +2. Map the affected consumers. Mark each dimension `covered`, `not applicable` + or `unknown`, with a reason: + - **Parser to runtime:** normalized objects, managers, launch options, + serialization boundaries and service inputs. + - **Alternate entry points:** TUI, exec, ACP, task/session launches, built-in + and project agents, and explicit historical-data importers where relevant. + - **Generated or cached values:** bundled prompts, static prompt caches, + compiled configuration, persisted state and memoized objects. + - **Fixtures and artifacts:** tests, snapshots, example agents/skills and + built distribution assets when affected. + - **String remnants:** old property names, environment/configuration keys, + log labels and documentation used by generation or tests. +3. Search for both old and new tokens beyond source files. Include ignored build + outputs explicitly when checking artifacts; an ordinary repository search may + skip them. Inspect raw serialized shapes where types or casts can hide drift. +4. Exercise the affected runtime paths through their final consumers. Check each + adapter that can bypass the tested path. Shared implementation is evidence + only after tracing how those adapters reach it. For prompts, check source + selection and caching; for identity/schema changes, check agent kinds that + parse differently. +5. Record commands, results, remaining old-token hits and compatibility reasons. + Inspect or regenerate caches using isolated test state; do not clear a user's + sessions or configuration as part of verification. + +## Completion bar + +Do not declare complete runtime coverage while a relevant consumer is `unknown` +or an unexplained old value remains. Report narrower passing checks and the +remaining gap. Parser tests alone do not establish propagation through launch, +execution and final presentation. + +Example evidence: + +```text +Parser to service: covered by the affected integration test. +Exec and ACP: covered by adapter tests; interactive TUI: not run. +Prompt cache: not applicable; the changed value is not cached. +Old field: retained only in the historical importer and its fixture. +Result: focused checks pass; interactive acceptance remains unverified. +``` diff --git a/AGENTS.md b/AGENTS.md index a12c4349d..f54db3bb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This repository is the reviewed public projection of an internal monorepo, not a - `packages/` — first-party workspace packages. `packages/agent-modules/*` is a second level of packages, not a package itself. - `third_party/` — vendored upstream packages (`pi-mono`, `sandbox-runtime`) with their own licenses. Their test suites are not part of this distribution's verification. - `release/` — machine-read release contracts. `extraction.json` pins the source baseline and package scope, `public-source.json` is the file inventory, `dependency-licenses.json` records declared dependency licenses. Build, type-check, test resolution, and source checks all read from here. -- `docs/` — human-read documentation only. +- `docs/` — human-readable documentation and supporting media. - `scripts/` — build and verification tooling. Shared constants live in `scripts/lib/`; import them instead of repeating literal paths or lists. - `test/` — repository-level tests and `vitest-suites.json`, the declaration of every Vitest file this distribution runs. @@ -20,7 +20,15 @@ Do not edit these by hand; regenerate them and commit the result. | `release/public-source.json` | `node scripts/source-inventory.mjs --write` | `pnpm check:source` | | `tsconfig.standalone.json` (the `paths` block) | `pnpm gen:tsconfig` | `pnpm check:tsconfig` | -Review what changed before regenerating the inventory: recording a file does not make it suitable for publication. +Review added, removed, and renamed files before regenerating the inventory: recording a file does not make it suitable for publication. Content-only edits to existing files do not require inventory regeneration. Keep private review material, verification reports, and temporary artifacts outside the repository; the inventory scans the working tree, including untracked files outside its explicit exclusions. + +## Branches and source synchronization + +Use a feature branch and submit a pull request; do not push directly to the default branch. See `CONTRIBUTING.md` for contribution and review requirements. + +Name new branches by the purpose of the change: `feat/` for features, `fix/` for bug fixes, and corresponding prefixes such as `docs/`, `refactor/`, `test/`, or `chore/` for other work. Use concise English descriptions in lowercase kebab-case, for example `feat/provider-limits` or `fix/session-restore`. Do not use agent or tool names as branch prefixes, including `codex/`. Follow an explicitly requested branch name when one is provided. + +Never merge internal Git history or cherry-pick internal commits into this repository. Follow `docs/source-sync.md`, keep unreviewed candidates outside the repository, and apply reviewed files individually. Advance `release/extraction.json`'s `sourceRevision` only after reviewing all differences for the selected source revision. ## Single sources of truth @@ -28,7 +36,7 @@ Review what changed before regenerating the inventory: recording a file does not | --- | --- | --- | | Package scope | `release/extraction.json` (`packageRoots`) | build, type-check paths, Vitest aliases, source check, source sync | | Package export → source file | each package's `exports` via `scripts/lib/package-exports.mjs` | `tsconfig.standalone.json`, `vitest.oss.config.mjs` | -| Test files per gate | `test/vitest-suites.json` | `vitest.oss.config.mjs`, `scripts/run-vitest-suite.mjs` | +| Vitest files per gate | `test/vitest-suites.json` | `vitest.oss.config.mjs`, `scripts/run-vitest-suite.mjs` | | Retired source paths | `scripts/lib/retired-sources.mjs` | `check:source` (must not exist), `check:standalone` (must not be bundled) | | Verification pipeline | `scripts/verify.mjs` | GitHub CI, `pnpm verify` | | Documentation-only classification | `scripts/ci-changes.mjs` | source verification, release audit | @@ -38,7 +46,9 @@ Review what changed before regenerating the inventory: recording a file does not Adding a workspace package: add it to `pnpm-workspace.yaml` and to `packageRoots` in `release/extraction.json`, then run `pnpm gen:tsconfig` and `node scripts/source-inventory.mjs --write`. -Adding a test file: add its path to the appropriate group in `test/vitest-suites.json`. Do not add paths to `package.json` scripts or the Vitest config. +Changing package exports: run `pnpm gen:tsconfig` after adding or changing an export subpath. + +Adding a Vitest file: add its path to the appropriate group in `test/vitest-suites.json`. Do not hard-code Vitest file paths in `package.json` scripts or the Vitest config. Repository-level `node:test` suites remain in their existing gates; add workflow safety and release-tool regressions to `test/source-sync.test.mjs`. Adding a verification gate: add a step to `scripts/verify.mjs`, with `platforms` when it cannot run everywhere. Do not add steps to the workflow file. @@ -46,8 +56,10 @@ Adding a verification gate: add a step to `scripts/verify.mjs`, with `platforms` `pnpm verify` runs the same gates as CI in the same order; `pnpm verify --list` shows which apply on the current platform. Run it before opening a pull request. Individual gates such as `pnpm typecheck`, `pnpm build`, and `pnpm test:byok` remain available for iteration. -The full profile is the local default. `platform` omits only duplicate type checking; `docs` runs the source/export gates for documentation-only changes; `archive` validates an authenticated source candidate without attempting a Git export. Keep profile selection in the shared verifier. Add workflow safety and release-tool regressions to the existing `test/source-sync.test.mjs` suite. +The full profile is the local default. `platform` omits only duplicate type checking. Use `pnpm verify --profile docs` only when every changed path qualifies under `scripts/ci-changes.mjs`; it runs source inventory, generated-path, source-export, and release-tool checks. `AGENTS.md`, bundled runtime prompts, and `release/` changes do not qualify for that profile. `archive` skips Git export for source archives; the candidate workflow authenticates the archive before invoking it. Keep profile selection in the shared verifier. + +Source export reads committed `HEAD` and rejects uncommitted tracked changes. During editing, run the relevant individual gates; run the complete applicable profile on the reviewed commit with a clean tracked working tree before opening a PR. Report the checks actually run and any blocked or untested boundaries. Offline tests do not establish live-service or cross-platform acceptance. ## Boundaries -Do not reference internal hosts, generated IDL, or private services; `check:source` rejects them. Do not restore paths listed in `scripts/lib/retired-sources.mjs`. Do not commit account data, sessions, logs, or credentials. Documentation and commit messages are written in English; see `CONTRIBUTING.md`. +Do not reference internal hosts, generated IDL, or private services; `check:source` catches known patterns but does not replace publication review. Do not restore paths listed in `scripts/lib/retired-sources.mjs` or remove supported capabilities to make standalone checks pass. Do not commit account data, sessions, logs, credentials, or real user content; use temporary data directories and synthetic test inputs. Documentation and commit messages are written in English; preserve the required languages of localized product strings and bundled runtime prompts. See `CONTRIBUTING.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbaade5de..3e4adf074 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,20 @@ Preserve original third-party license text. Localized product strings, multiling ## Local validation +Repository development skills are available under [`.agents/skills`](.agents/skills): + +| Skill | Use | +| --- | --- | +| [cli-guide](.agents/skills/cli-guide/SKILL.md) | Navigate TUI, exec, ACP and their runtime boundary | +| [cross-layer-drift-sweep](.agents/skills/cross-layer-drift-sweep/SKILL.md) | Check renames, defaults and contracts across consumers | +| [testing-workflow](.agents/skills/testing-workflow/SKILL.md) | Select focused checks and the required delivery gates | +| [verify-all-runtime-sinks](.agents/skills/verify-all-runtime-sinks/SKILL.md) | Verify alternate runtime paths, caches and artifacts | +| [retro](.agents/skills/retro/SKILL.md) | Turn demonstrated failures into focused guidance improvements | + +These are contributor workflows, separate from bundled product skills. They are +adapted to this distribution's paths and verification contracts; workflow changes +should update the relevant skill alongside its source of truth. + ```bash pnpm install --frozen-lockfile pnpm verify diff --git a/README.md b/README.md index 7d1d9722c..a3cfd6650 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ curl -fsSL https://filecdn.minimax.chat/public/install.sh | bash irm https://filecdn.minimax.chat/public/install.ps1 | iex ``` +The scripts install into `~/.minimax-code` on macOS / Linux / WSL and `%USERPROFILE%\.minimax-code` on Windows. The launchers are `bin/mcode` and `bin/mcode-tools` on POSIX, or `mcode.cmd` / `mcode.ps1` and `mcode-tools.cmd` / `mcode-tools.ps1` on Windows. Set `MCODE_INSTALL_DIR` before installation to choose a different location. See [Uninstall](#uninstall) to remove the CLI. + **npm** — if you already have **Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26**: ```bash @@ -78,7 +80,7 @@ mcode login --region global Complete sign-in in your browser, then open `mcode` and use `/status` to check your account and `/provider` to choose a model. Run `mcode logout` to sign out. -Token Plan requires an account with available credits. User data is stored in `~/.minimax-code` by default. +Token Plan requires an account with available credits. Builds from this repository and the published npm CLI `@minimax-ai/code@0.4.12` default to `~/.minimax` for user data (or `~/.minimax-` when a profile is selected). `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` can override the data directory. The installer's `~/.minimax-code` installation directory is separate from this choice. See [Accounts and data](docs/installation.md#accounts-and-data) before locating or removing configuration and sessions.
Use your own API key (BYOK) @@ -92,6 +94,8 @@ mcode provider add --name my-provider --base-url https://example.com/v1 \ mcode ``` +`--use` tests the first listed model before saving and selecting it. A failed connection test saves nothing. Omit `--use` to save without testing or changing the default model. For custom/local models, add `--context-limit 32768 --output-limit 4096` (use your server's actual limits). Each value must be a positive safe integer and applies to every repeated `--model`. Inspect configured limits with `mcode provider list --json`. Omitting these flags preserves the existing model-limit defaults. + Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run.
@@ -145,6 +149,56 @@ Inside the TUI, use `/sessions` to find previous sessions and `/help` to see all | Switch permission modes | `Alt+M` | | Close a panel or interrupt a running task | `Esc` | +## Uninstall + +Close running MCode sessions, including editor integrations, before uninstalling. First locate the command with `command -v mcode` (macOS / Linux / WSL) or `Get-Command mcode -All` (PowerShell), then follow the matching installation method below. The current official install scripts do **not** provide an uninstall flag. + +The commands below remove the default installation directory, including both launchers, downloaded releases, and any installer-managed Node.js runtime. If you used `MCODE_INSTALL_DIR`, substitute the actual installation directory. Inspect it first: earlier source builds used `~/.minimax-code` for user data, and a custom data directory can overlap the installation. Back up any configuration or sessions you want to keep before deleting it. + +**macOS / Linux / WSL** + +```bash +rm -rf -- "$HOME/.minimax-code" +``` + +Remove the `# MiniMax Code CLI` comment and its following PATH line from the shell file the installer updated: `~/.zshrc` for zsh; the first existing file among `~/.bashrc`, `~/.bash_profile`, and `~/.profile` for bash (or a newly created `~/.bashrc`); `~/.config/fish/config.fish` for fish; or `~/.profile` for other shells. The line is `export PATH="/absolute/install/path/bin:$PATH"`, or `fish_add_path -g "/absolute/install/path/bin"` for fish. Remove only the MCode entry, preserving other PATH settings. The installer skips this edit when `MCODE_NO_MODIFY_PATH` is set or the path is already present. + +**Windows (PowerShell)** + +```powershell +Remove-Item -LiteralPath "$env:USERPROFILE\.minimax-code" -Recurse -Force +``` + +Open **Edit environment variables for your account**, edit the user **Path**, and remove only the installation directory entry (by default `%USERPROFILE%\.minimax-code`, which may appear as an expanded absolute path). The Windows installer updates the user Path, not the PowerShell profile; `MCODE_NO_MODIFY_PATH` skips that persistent update. + +### Installed with npm or from source + +For a global npm installation, use the same npm installation/prefix you used to install MCode: + +```bash +npm uninstall -g @minimax-ai/code +``` + +For a source build, save any work and remove only the checkout you created; see [Update or remove](docs/installation.md#update-or-remove). + +After uninstalling, reopen your terminal (fully restart the editor for integrated terminals) and run `command -v mcode` or `Get-Command mcode -All` again. No result means the command is no longer on PATH. If another copy appears, identify its installation method before removing it. + +### Optional: delete user data + +Removing the program leaves separately stored user data in place. To also delete local login state, provider configuration, caches, and sessions, first confirm the selected directory using [Accounts and data](docs/installation.md#accounts-and-data) and back up anything you need. Other MCode installations can share this directory. For the default `~/.minimax` directory only: + +```bash +# macOS / Linux / WSL — permanently deletes the default user data +rm -rf -- "$HOME/.minimax" +``` + +```powershell +# Windows — permanently deletes the default user data +Remove-Item -LiteralPath "$env:USERPROFILE\.minimax" -Recurse -Force +``` + +A profile uses `~/.minimax-`; `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` can select a different location. Remove only the specific directories you intend to discard, without wildcard deletion. Remove any MCode-specific environment variable assignments you added to shell profiles or user environment settings if you no longer need them. + ## Network egress This fork ships no telemetry and no managed-service client, and it decides every @@ -208,7 +262,9 @@ For now, code and documentation pull requests are accepted only from repository ## Desktop app and support -MiniMax Code desktop app + + MiniMax Code desktop app — click to download + [Download for macOS or Windows](https://agent.minimax.io/download) · [Report a problem or ask a question](https://github.com/MiniMax-AI/minimax-code/issues/new/choose) diff --git a/README_ZH.md b/README_ZH.md index 7596f2fac..32d3ab2b9 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -45,6 +45,8 @@ curl -fsSL https://filecdn.minimax.chat/public/install.sh | bash irm https://filecdn.minimax.chat/public/install.ps1 | iex ``` +脚本在 macOS / Linux / WSL 上默认安装到 `~/.minimax-code`,在 Windows 上默认安装到 `%USERPROFILE%\.minimax-code`。POSIX 启动器为 `bin/mcode` 和 `bin/mcode-tools`;Windows 启动器为 `mcode.cmd` / `mcode.ps1` 和 `mcode-tools.cmd` / `mcode-tools.ps1`。安装前设置 `MCODE_INSTALL_DIR` 可更改安装位置。移除 CLI 的方法见[卸载](#卸载)。 + **npm**:适用于已安装 **Node.js 22.19+(22.x)、24.2+(24.x)、25 或 26** 的环境。 ```bash @@ -78,7 +80,7 @@ mcode login --region global 在浏览器中完成登录,再启动 `mcode`,通过 `/status` 检查账号、通过 `/provider` 选择模型。退出登录使用 `mcode logout`。 -Token Plan 需要账号与可用额度。默认用户数据保存在 `~/.minimax-code`。 +Token Plan 需要账号与可用额度。从本仓库构建的版本与已发布的 npm CLI `@minimax-ai/code@0.4.12` 均默认将用户数据保存在 `~/.minimax`(选择 profile 时为 `~/.minimax-`)。`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 可以覆盖数据目录。安装脚本使用的 `~/.minimax-code` 安装目录与数据目录的选择是两回事。查找或删除配置和会话前,请参阅[账号与数据](docs/installation.md#accounts-and-data)。
使用自己的 API Key(BYOK) @@ -92,6 +94,8 @@ mcode provider add --name my-provider --base-url https://example.com/v1 \ mcode ``` +`--use` 会先测试第一个模型,成功后保存并设为默认模型;连接测试失败时不保存。省略 `--use` 则仅保存,不测试,也不改变默认模型。对于自定义或本地模型,可添加 `--context-limit 32768 --output-limit 4096`(请填写服务器的实际限制)。两个值都必须是正安全整数,并应用于所有重复指定的 `--model`。可通过 `mcode provider list --json` 查看已配置的限制。省略这两个参数时保持现有的模型限制默认值。 + 支持 `openai-completions`、`openai-responses` 和 `anthropic-messages`。连接测试、单次模型切换及环境变量设置见 [模型示例](docs/examples.md#2-choose-your-own-model)。
@@ -141,6 +145,58 @@ mcode --session | 切换权限模式 | `Alt+M` | | 关闭面板或中断正在运行的任务 | `Esc` | +## 卸载 + +卸载前请关闭正在运行的 MCode 会话,包括编辑器中的集成。先通过 `command -v mcode`(macOS / Linux / WSL)或 `Get-Command mcode -All`(PowerShell)定位命令,再按对应的安装方式操作。当前官方安装脚本**不提供卸载参数**。 + +### 通过脚本安装 + +以下命令会删除默认安装目录,包括两个启动器、下载的版本,以及安装器管理的 Node.js 运行时。如果使用过 `MCODE_INSTALL_DIR`,请替换为实际安装目录。删除前请先检查:早期源码构建曾将用户数据保存在 `~/.minimax-code`,自定义数据目录也可能与安装目录重合。请先备份需要保留的配置和会话。 + +**macOS / Linux / WSL** + +```bash +rm -rf -- "$HOME/.minimax-code" +``` + +从安装器修改的 shell 配置文件中删除 `# MiniMax Code CLI` 注释及其下一行 PATH 配置:zsh 使用 `~/.zshrc`;bash 依次选择 `~/.bashrc`、`~/.bash_profile`、`~/.profile` 中第一个已存在的文件,均不存在时创建 `~/.bashrc`;fish 使用 `~/.config/fish/config.fish`;其他 shell 使用 `~/.profile`。对应配置为 `export PATH="/absolute/install/path/bin:$PATH"`,fish 则为 `fish_add_path -g "/absolute/install/path/bin"`。只移除 MCode 对应的条目,保留其他 PATH 设置。如果设置了 `MCODE_NO_MODIFY_PATH` 或路径已存在,安装器会跳过此修改。 + +**Windows(PowerShell)** + +```powershell +Remove-Item -LiteralPath "$env:USERPROFILE\.minimax-code" -Recurse -Force +``` + +打开“编辑账户的环境变量”,编辑用户 **Path**,仅删除安装目录对应的条目(默认为 `%USERPROFILE%\.minimax-code`,也可能显示为展开后的绝对路径)。Windows 安装器修改的是用户 Path,不是 PowerShell 配置文件;设置 `MCODE_NO_MODIFY_PATH` 会跳过此持久化修改。 + +### 通过 npm 或源码安装 + +全局 npm 安装请使用当初安装 MCode 时的同一套 npm 和安装前缀: + +```bash +npm uninstall -g @minimax-ai/code +``` + +源码构建请先保存工作,再仅删除自己创建的源码目录,详见[更新或移除](docs/installation.md#update-or-remove)。 + +卸载后重新打开终端(编辑器集成终端需要完全重启编辑器),再次运行 `command -v mcode` 或 `Get-Command mcode -All`。没有结果表示 PATH 中已找不到该命令。如果出现另一份安装,请先确认它的安装方式再移除。 + +### 可选:删除用户数据 + +移除程序会保留单独存储的用户数据。如果还要删除本地登录状态、提供方配置、缓存和会话,请先按[账号与数据](docs/installation.md#accounts-and-data)确认实际数据目录,并备份需要保留的内容。其他 MCode 安装可能共用该目录。以下命令仅适用于默认的 `~/.minimax`: + +```bash +# macOS / Linux / WSL — 永久删除默认用户数据 +rm -rf -- "$HOME/.minimax" +``` + +```powershell +# Windows — 永久删除默认用户数据 +Remove-Item -LiteralPath "$env:USERPROFILE\.minimax" -Recurse -Force +``` + +profile 使用 `~/.minimax-`;`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 可以指定其他位置。只删除确定不再需要的具体目录,不要使用通配符批量删除。如果不再需要自行添加的 MCode 环境变量,也请从 shell 配置或用户环境变量设置中移除对应赋值。 + ## 可以做什么 | 场景 | 使用方式 | @@ -195,7 +251,9 @@ node /absolute/path/to/minimax-code/dist/cli.js ## 桌面版与问题反馈 -MiniMax Code 桌面版 + + MiniMax Code 桌面版 — 点击下载 + [下载 macOS 或 Windows 桌面版](https://agent.minimaxi.com/download) · [报告问题或提问](https://github.com/MiniMax-AI/minimax-code/issues/new/choose) diff --git a/docs/examples.md b/docs/examples.md index 40e2e6897..12a536569 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -50,7 +50,21 @@ pnpm mcode provider test --model pnpm mcode exec "Explain this project's test entry points" --model / ``` -Replace the example URL, model name, and IDs with your configuration and the IDs returned by the list command. `--use` sets the default model; `exec --model` overrides only the current run. Backslash line continuations are for POSIX shells; use a single line in PowerShell. +Replace the example URL, model name, and IDs with your configuration and the IDs returned by the list command. `--use` tests the first listed model, then saves the provider and selects that model as the default. A failed connection test exits nonzero without saving or changing the default; correct the URL, key, or first model ID and retry. Omit `--use` to save without a connection test or default-model change. `exec --model` overrides only the current run. Backslash line continuations are for POSIX shells; use a single line in PowerShell. + +For a local server, configure its actual token limits explicitly: + +```bash +pnpm mcode provider add --name local-models --base-url http://localhost:8080/v1 \ + --api-format openai-completions --model local-model --model another-model \ + --api-key-env MCODE_PROVIDER_API_KEY \ + --context-limit 32768 --output-limit 4096 --use +pnpm mcode provider list --json +``` + +`--context-limit` and `--output-limit` each accept a positive safe integer (at most `9007199254740991`). Either flag can be used independently. The same limits apply to every repeated `--model`; only the first model is tested and selected by `--use`. The JSON list shows the configured values as `contextLimit` and `maxOutputTokens`. Without these flags, the existing defaults remain unchanged (unknown custom models currently fall back to 200,000 context tokens and 16,384 output tokens). Model discovery does not infer your local server's context size. + +`--api-key-env` reads the current environment variable value and stores that value in the active profile's `config.yaml`; it does not save an environment-variable reference. The file still contains plaintext credentials. On POSIX systems, config writes and temporary copies use `0600`. When loading existing files, MCode removes group/other access while preserving the owner's permissions; already-private files such as `0400` or `0600` do not require a permission change. Loading fails if an unsafe main config cannot be restricted. Older migration backups are also checked, but inspection or repair failures produce a warning identifying the directory or backup that needs manual attention rather than preventing the main config from loading. Windows file modes do not provide equivalent ACL protection; restrict access to the profile directory using Windows permissions. [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. @@ -71,4 +85,45 @@ pnpm mcode exec "Describe this UI screenshot's layout and suggest three improvem The image is sent as input to the selected model service. Use content suitable for sending and a model that supports images. This is an executable usage example, not a live-service acceptance result from this review. Search, image understanding, and media generation are separate capabilities; mcode-tools generation also requires the relevant account permissions and credits. -Use `/plugins` to manage extensions. See [capability coverage](tui-capabilities.md) for custom MCP, managed connectors, and media tools. +See [capability coverage](tui-capabilities.md) for custom MCP, managed connectors, and media tools. + +## 4. Manage plugins + +Open `/plugins` inside the TUI, or run `mcode plugin` from a shell to open that panel. For a source build, use `pnpm mcode plugin` from the source root instead; the commands below use the installed `mcode` executable. + +The panel combines the **official** catalog and **local** plugin directories. Use `Tab` / `Shift+Tab` to switch between All Plugins, Installed, Official, and Local; type to search and use the arrow keys to select a row. + +| Action | Key | Scope | +| --- | --- | --- | +| Install an available plugin | `Enter` | Official catalog; requires MiniMax login | +| Enable or disable an installed plugin | `Space` | Official and local | +| Remove an installed plugin | `Delete` or `Ctrl+D` | Official and local; clear the search first with `Esc`, then reselect the plugin | +| Refresh the catalogs | `Ctrl+R` | Official and local | + +With a nonempty search, `Delete` / `Ctrl+D` edit the search instead of removing a plugin. `Esc` clears the search, or closes the panel when the search is already empty. Removing a local plugin deletes its installed directory; keep a separate source copy if you need to restore it. + +The same operations are available from the shell: + +```bash +mcode plugin --help +mcode plugin marketplace list +mcode plugin list --available --marketplace official +mcode plugin add @official +mcode plugin disable @official +mcode plugin enable @official +mcode plugin remove @official +mcode plugin marketplace upgrade +``` + +Replace `` with a plugin name returned by `list`. Use `@official` or `@local` to disambiguate names shared by both sources; `--marketplace official` / `--marketplace local` are equivalent source selectors. The list and mutation commands support `--json`. `marketplace upgrade` refreshes source snapshots; it does not register a new marketplace. + +For a local plugin, run `mcode plugin marketplace list` to find the active profile's local directory. Place a supported plugin package in a direct child directory there, with its manifest at the package's expected location, then refresh `/plugins`. Copy the individual plugin package, not an entire marketplace repository. Discovered local packages already count as installed: + +```bash +mcode plugin list --available --marketplace local +mcode plugin disable @local +mcode plugin enable @local +mcode plugin remove @local +``` + +`mcode plugin add @local` is not a local import command and is unsupported. Neither `plugin add` nor `/plugins` currently accepts a GitHub URL, local path, or arbitrary third-party marketplace registration. Compatible package readers and a GitHub importer exist in the runtime, but the CLI/TUI do not expose that importer. Managing arbitrary marketplaces from the panel remains a separate feature request; the current source selectors are only `official` and `local`. diff --git a/docs/installation.md b/docs/installation.md index d2a91d761..843622a7a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -39,10 +39,29 @@ On Windows, also use `node` with the appropriate local absolute path. Do not ove Run `/login` in the TUI or `pnpm mcode login`, choosing the region for your account. Token Plan requires an account and available credits. See the root README for BYOK configuration and testing. -The default data directory is `~/.minimax-code`. For tests, explicitly set `MINIMAX_DATA_DIR` to a temporary directory to keep normal sessions separate. Use `$env:MINIMAX_DATA_DIR = 'C:\path\to\test-profile'` in PowerShell or `export MINIMAX_DATA_DIR=/path/to/test-profile` in a POSIX shell. +Source builds and the published npm CLI use the same default user data directory: + +| CLI artifact | Default user data directory | +| --- | --- | +| Published npm `@minimax-ai/code@0.4.12` | `~/.minimax` | +| Build from this repository | `~/.minimax` | + +The npm 0.4.12 default was checked against the [public registry artifact](https://registry.npmjs.org/@minimax-ai/code/0.4.12) on 2026-09-19, with its SHA-512 integrity verified. This applies to that published version, including local npm dependencies; do not infer the default of another release from its version label alone. A selected profile uses `~/.minimax-`. The source default is defined in [`data-dir.ts`](../packages/tui/src/runtime/data-dir.ts). Login state, provider configuration such as `config.yaml`, caches, and sessions belong to the selected data directory. + +Both accept a non-empty `MINIMAX_DATA_DIR`, falling back to a non-empty `MAVIS_DATA_DIR`, before the default. The [macOS / Linux / WSL installer](https://filecdn.minimax.chat/public/install.sh) installs the npm package under `~/.minimax-code` by default (`MCODE_INSTALL_DIR` changes the installation location). It does not set either data-directory override. Installation files and user data are separate concerns, even when their directories have the same name. + +Earlier source builds used `~/.minimax-code` for user data. The new default does not move or merge that data. To keep using an existing source-build data directory, explicitly set `MINIMAX_DATA_DIR` to its path. + +To locate data safely: + +1. Identify the launcher you actually use with `command -v mcode` (POSIX) or `Get-Command mcode -All` (PowerShell), and run that launcher's `--version`. For a local npm dependency, use `node_modules/.bin/mcode --version`; a global npm listing does not identify it. A source build may report the same version as the npm release. +2. Check whether either data-directory override is set in that launcher's environment. Otherwise use the artifact-specific default above. Inspect directory and file names/permissions locally, without printing `config.yaml`, authentication files, or session contents. For builds from this repository, `mcode telemetry status` also reports the selected `configFile` path without printing credentials; npm 0.4.12 does not provide that command. +3. If both directories exist, their presence alone does not identify the active one. Keep both protected, and include the launcher, version, installation method, and whether overrides are set when requesting help. Redact personal path components; do not attach configuration or authentication files. Changing an override does not migrate existing data, so do not move or delete either directory merely to match these docs. + +For tests, explicitly set `MINIMAX_DATA_DIR` to a temporary directory to keep normal sessions separate. Use `$env:MINIMAX_DATA_DIR = 'C:\path\to\test-profile'` in PowerShell or `export MINIMAX_DATA_DIR=/path/to/test-profile` in a POSIX shell. ## Update or remove Save your changes, fetch a reviewed revision with Git, then repeat the frozen install and build. A source installation does not automatically become an official npm installation. -To uninstall, remove the source directory you created. User data is separate and remains in place. Delete that directory only when you no longer need its account configuration or sessions. +To uninstall a source build, save any work and remove the source directory you created. Separately stored user data remains in place. See [Uninstall](../README.md#uninstall) for official installer and npm removal, shell PATH cleanup, and optional user-data deletion. diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 1833a1403..84dfe2708 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -11,7 +11,7 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | GitHub Copilot provider (fork addition) | Connector signs in to a Copilot subscription and reads the account's live `/models` catalog: per-model context and output limits, the reasoning-effort levels the account reports, and the wire protocol each model advertises. Sign-in is the GitHub device flow through pi's Copilot OAuth provider, or a token supplied out of band; the credential stays in the provider credential store | Discovery and connector tests run against a captured catalog; live completions passed on `openai-responses`, `anthropic-messages` and `openai-completions`; `mcode exec` passed end to end on all three protocols and in all four egress modes. Both TUI entry points carry the connect action and run the device flow: the model picker row, and a `/provider` row that Runtime synthesizes from the sign-in status before any entry exists. Once the connector has written its entry, that entry is the only Copilot row — Codex instead keeps its synthetic row beside the entry. The enterprise API host is unit-tested only | | mcode-tools | Public package's unchanged 0.0.4 artifact, launcher, and short-lived token leases restored | Archive SHA-512, CLI SHA-256, real CLI startup, lease and host integration tests; production shared-broker authentication passed | | Search and Matrix image / audio / video tools | Matrix MCP and tool assembly restored; search is independent of mcode-tools | MCP configuration and auth-isolation tests; actual search passed; generation requests not run | -| Official, local, and GitHub plugins | Official sources, marketplace reads, and installation control restored; local sources retained | Plugin application, source-page, and offline local CLI tests; actual official catalog read passed | +| Official and local plugins; runtime GitHub importer | `/plugins` and `mcode plugin` manage official installations and discovered local packages; arbitrary marketplace registration and GitHub URL import are not exposed in the CLI/TUI. See [plugin management](examples.md#4-manage-plugins) | Plugin application, source-page, and offline local CLI tests; actual official catalog read passed | | Managed connectors | Cloud client, permissions, and invocation adapters restored | Connector runtime and cloud transport tests; actual tool discovery passed; business writes not run | | Updates | Update entry points, install-source detection, and signature verification restored; public packages use public npm | Update application and service tests; no real installation / upgrade on the development machine | | Feedback and diagnostic uploads | Reviewed feedback text and minimized diagnostic summaries | Synthetic fixtures exercise session collection and capture/decode the final ZIP upload; no live uploads or real content | diff --git a/packages/agent-core/src/bash-subprocess-env.ts b/packages/agent-core/src/bash-subprocess-env.ts index 4cf39ca91..c52cb0da3 100644 --- a/packages/agent-core/src/bash-subprocess-env.ts +++ b/packages/agent-core/src/bash-subprocess-env.ts @@ -11,8 +11,8 @@ * parent session against the runtime API, or a nested `mavis` CLI inside * bash would silently bind to the parent runtime's profile. * - * Layer B — third-party secret scrub. GATED, mirroring the reference CLI's - * `subprocessEnv()` design (src/utils/subprocessEnv.ts): + * Layer B — provider and integration secret scrub. GATED, mirroring the + * reference CLI's `subprocessEnv()` design (src/utils/subprocessEnv.ts): * - `off` (interactive default): do not strip user secrets. CC parity — * interactive users rely on env credentials (gh, npm, ...). * - `scrub` (auto in CI / non-interactive): precise blocklist of @@ -74,6 +74,7 @@ export interface BashEnvSanitizeResult { */ export const BASH_SUBPROCESS_SCRUB: readonly string[] = [ // Provider / LLM auth — the runtime re-reads these per-request itself + 'MCODE_PROVIDER_API_KEY', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', @@ -197,7 +198,7 @@ export function sanitizeBashSubprocessEnv( // Layer A — MCode boundary strip. Always on; allowlist deliberately ignored. removed.push(...stripRuntimeBoundaryKeysFrom(out, 'agent-runtime')); - // Layer B — third-party secret scrub, gated by mode. + // Layer B — provider and integration secret scrub, gated by mode. if (policy.mode === 'scrub') { for (const key of BASH_SUBPROCESS_SCRUB) { for (const name of [key, `INPUT_${key}`]) { diff --git a/packages/agent-core/test/unit/bash-subprocess-env.test.ts b/packages/agent-core/test/unit/bash-subprocess-env.test.ts new file mode 100644 index 000000000..6158a2fec --- /dev/null +++ b/packages/agent-core/test/unit/bash-subprocess-env.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + createBashEnvSpawnHook, + resolveBashEnvPolicy, + sanitizeBashSubprocessEnv, +} from '../../src/bash-subprocess-env.js'; + +const providerEnv = { + MCODE_PROVIDER_API_KEY: 'synthetic-provider-value', + INPUT_MCODE_PROVIDER_API_KEY: 'synthetic-input-value', + MCODE_PROVIDER_BASE_URL: 'https://provider.example.invalid', + MCODE_PROVIDER_MODEL: 'synthetic-model', + CUSTOM_PROVIDER_API_KEY: 'synthetic-custom-value', + GH_TOKEN: 'synthetic-gh-value', + GITHUB_TOKEN: 'synthetic-github-value', + NPM_TOKEN: 'synthetic-npm-value', +}; + +describe('bash subprocess default provider credentials', () => { + it.each([{ CI: 'true' }, { GITHUB_ACTIONS: 'true' }, { MAVIS_BASH_ENV_SANITIZE: 'scrub' }])( + 'scrubs both provider key names using policy %j', + (markers) => { + const original = { ...providerEnv, ...markers }; + const policy = resolveBashEnvPolicy(undefined, original); + expect(policy.mode).toBe('scrub'); + const { env, removed } = sanitizeBashSubprocessEnv(original, policy); + const { MCODE_PROVIDER_API_KEY, INPUT_MCODE_PROVIDER_API_KEY, ...preserved } = original; + expect(env).toEqual(preserved); + expect(removed).toEqual(['INPUT_MCODE_PROVIDER_API_KEY', 'MCODE_PROVIDER_API_KEY']); + expect(original.MCODE_PROVIDER_API_KEY).toBe(MCODE_PROVIDER_API_KEY); + expect(original.INPUT_MCODE_PROVIDER_API_KEY).toBe(INPUT_MCODE_PROVIDER_API_KEY); + }, + ); + + it('applies scrub to the actual spawn-hook environment without mutating its input', () => { + const original = { command: 'echo synthetic', cwd: '.', env: { ...providerEnv } }; + const result = createBashEnvSpawnHook({ mode: 'scrub' })(original); + expect(result.env).not.toHaveProperty('MCODE_PROVIDER_API_KEY'); + expect(result.env).not.toHaveProperty('INPUT_MCODE_PROVIDER_API_KEY'); + expect(result.command).toBe(original.command); + expect(original.env).toEqual(providerEnv); + }); + + it('preserves credentials when off is explicitly selected, including in CI', () => { + const policy = resolveBashEnvPolicy({ mode: 'off' }, { CI: 'true' }); + expect(sanitizeBashSubprocessEnv(providerEnv, policy)).toEqual({ + env: providerEnv, + removed: [], + }); + }); + + it('continues to strip both provider names in strict mode', () => { + const { env, removed } = sanitizeBashSubprocessEnv(providerEnv, { mode: 'strict' }); + expect(env).not.toHaveProperty('MCODE_PROVIDER_API_KEY'); + expect(env).not.toHaveProperty('INPUT_MCODE_PROVIDER_API_KEY'); + expect(removed).toContain('MCODE_PROVIDER_API_KEY'); + expect(removed).toContain('INPUT_MCODE_PROVIDER_API_KEY'); + expect(env.MCODE_PROVIDER_MODEL).toBe(providerEnv.MCODE_PROVIDER_MODEL); + expect(env.MCODE_PROVIDER_BASE_URL).toBe(providerEnv.MCODE_PROVIDER_BASE_URL); + }); + + it('retains the explicit strict allowlist escape hatch', () => { + const { env } = sanitizeBashSubprocessEnv(providerEnv, { + mode: 'strict', + allowlist: ['MCODE_PROVIDER_API_KEY', 'INPUT_MCODE_PROVIDER_API_KEY'], + }); + expect(env.MCODE_PROVIDER_API_KEY).toBe(providerEnv.MCODE_PROVIDER_API_KEY); + expect(env.INPUT_MCODE_PROVIDER_API_KEY).toBe(providerEnv.INPUT_MCODE_PROVIDER_API_KEY); + }); +}); diff --git a/packages/agent-modules/context-manager/src/manager.ts b/packages/agent-modules/context-manager/src/manager.ts index af1398ceb..acd72d34f 100644 --- a/packages/agent-modules/context-manager/src/manager.ts +++ b/packages/agent-modules/context-manager/src/manager.ts @@ -1,8 +1,4 @@ -import { - generateSummary, - type AgentMessage, - type CompactionSummaryMessage, -} from '@earendil-works/pi-agent-core'; +import { generateSummary, type AgentMessage } from '@earendil-works/pi-agent-core'; import type { PiBeforeLlmCallHook, PiBeforeLlmCallHookDecision, @@ -13,6 +9,7 @@ import { computeCompactionTriggerAt, DEFAULT_CONTEXT_MANAGER_SETTINGS } from './ import { createDefaultContextTokenEstimator, type TokenEstimator } from './token-estimator.js'; import type { ContextCompactionPlan, + ContextCompactionSummaryMessage, ContextManagerCheckpointOptions, ContextManagerLock, ContextManagerObserver, @@ -116,7 +113,7 @@ export class ContextManager { const replacementId = this.idGenerator(); const replacementMessages = [ - createCompactionSummary(summary, plan.tokensBefore, this.nowMs()), + createCompactionSummary(summary, plan.tokensBefore, this.nowMs(), plan.keptMessages.length), ...plan.keptMessages, ]; const tokensAfter = this.tokenEstimator.estimateContextTokens(replacementMessages).tokens; @@ -510,12 +507,14 @@ function createCompactionSummary( summary: string, tokensBefore: number, timestamp: number, -): CompactionSummaryMessage { + keptMessageCount: number, +): ContextCompactionSummaryMessage { return { role: 'compactionSummary', summary, tokensBefore, timestamp, + keptMessageCount, }; } diff --git a/packages/agent-modules/context-manager/src/token-estimator.ts b/packages/agent-modules/context-manager/src/token-estimator.ts index 443991202..9b4c6e7f2 100644 --- a/packages/agent-modules/context-manager/src/token-estimator.ts +++ b/packages/agent-modules/context-manager/src/token-estimator.ts @@ -20,7 +20,7 @@ * Ground-truth-aware estimate * --------------------------- * `estimateContextTokens` preserves pi-agent-core's good pattern: - * 1. Find the last successful assistant message with a usage block; + * 1. Find the last successful assistant message with usage for the current context; * 2. Trust its provider-reported total as the prefix sum; * 3. Estimate only the trailing messages after that point. * This bounds estimator error to the trailing window — typically a handful of @@ -48,6 +48,7 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core'; import { Buffer } from 'node:buffer'; import { countTokens as countO200kBase } from 'gpt-tokenizer/model/gpt-4o'; +import type { ContextCompactionSummaryMessage } from './types.js'; export interface ContextTokenEstimate { /** Estimated total tokens consumed by `messages`. */ @@ -163,8 +164,36 @@ function getAssistantUsage(message: AgentMessage): AssistantUsageRef | undefined function getLastAssistantUsageInfo( messages: AgentMessage[], ): { usage: AssistantUsageRef; index: number } | undefined { + let firstFreshIndex = 0; + let compactedAt: number | undefined; for (let i = messages.length - 1; i >= 0; i -= 1) { - const usage = getAssistantUsage(messages[i]!); + if (messages[i]!.role !== 'compactionSummary') continue; + const summary = messages[i] as ContextCompactionSummaryMessage; + const keptCount = summary.keptMessageCount; + if (keptCount !== undefined) { + // Kept assistants appear AFTER the summary too. Only appended responses + // describe the replacement context; their wall-clock timestamps may tie + // or precede the summary if the clock changed. + if (!Number.isSafeInteger(keptCount) || keptCount < 0) return undefined; + firstFreshIndex = i + 1 + keptCount; + } else { + // Legacy persisted transcripts have no explicit retained-tail boundary. + // Match the local runtime's conservative timestamp freshness rule. + if (!Number.isFinite(summary.timestamp)) return undefined; + compactedAt = summary.timestamp; + firstFreshIndex = i + 1; + } + break; + } + for (let i = messages.length - 1; i >= firstFreshIndex; i -= 1) { + const message = messages[i]!; + if ( + compactedAt !== undefined && + (!Number.isFinite(message.timestamp) || message.timestamp <= compactedAt) + ) { + continue; + } + const usage = getAssistantUsage(message); if (usage) return { usage, index: i }; } return undefined; diff --git a/packages/agent-modules/context-manager/src/types.ts b/packages/agent-modules/context-manager/src/types.ts index 38d1cf2ca..fb5a38a4a 100644 --- a/packages/agent-modules/context-manager/src/types.ts +++ b/packages/agent-modules/context-manager/src/types.ts @@ -1,6 +1,20 @@ -import type { AgentMessage, StreamFn, ThinkingLevel } from '@earendil-works/pi-agent-core'; +import type { + AgentMessage, + CompactionSummaryMessage, + StreamFn, + ThinkingLevel, +} from '@earendil-works/pi-agent-core'; import type { Api, Model, Tool } from '@earendil-works/pi-ai'; +export interface ContextCompactionSummaryMessage extends CompactionSummaryMessage { + /** + * Number of following messages retained from the old context, whose usage is stale. + * Persist this with the summary so a restored transcript has the same usage boundary. + * Absent on legacy summaries, which must use timestamp-based freshness instead. + */ + keptMessageCount?: number; +} + export interface ContextManagerSettings { enabled: boolean; reserveTokens: number; diff --git a/packages/agent-modules/context-manager/test/compaction-usage.test.ts b/packages/agent-modules/context-manager/test/compaction-usage.test.ts new file mode 100644 index 000000000..2ba54c77e --- /dev/null +++ b/packages/agent-modules/context-manager/test/compaction-usage.test.ts @@ -0,0 +1,287 @@ +import type { + AgentMessage, + CompactionSummaryMessage, +} from "@earendil-works/pi-agent-core"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { PiBeforeLlmCallHookInput } from "@mavis/agent-core/pi-turn-runner"; +import { describe, expect, it, vi } from "vitest"; +import { ContextManager } from "../src/manager.js"; +import { BpeTokenEstimator } from "../src/token-estimator.js"; + +const estimator = new BpeTokenEstimator(); +function assistant(tokens = 180_100, timestamp = 10): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "Retained reply." }], + api: "anthropic-messages", + provider: "test", + model: "test", + stopReason: "stop", + timestamp, + usage: { + input: tokens - 100, + output: 100, + cacheRead: 0, + cacheWrite: 0, + totalTokens: tokens, + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, total: 3 }, + }, + }; +} +function user(content = "Continue."): AgentMessage { + return { role: "user", content, timestamp: 10 }; +} +function summary(timestamp = 20): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary: "Small summary.", + tokensBefore: 180_100, + timestamp, + }; +} +function input(messages: AgentMessage[]): PiBeforeLlmCallHookInput { + return { + sessionId: "synthetic-session", + turnId: "synthetic-turn", + phase: "iteration", + messages, + canonicalMessages: messages, + thinkingLevel: "off", + model: { + id: "test", + name: "test", + api: "anthropic-messages", + provider: "test", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 1024, + }, + }; +} +function history(): AgentMessage[] { + return [ + user("Old request."), + assistant(), + user("Old follow-up."), + assistant(), + user("Latest request."), + assistant(), + ]; +} + +describe("compaction usage provenance", () => { + it("locally counts a restored legacy summary and retained pre-compaction reply", () => { + const messages = JSON.parse(JSON.stringify([summary(), assistant()])); + const estimate = estimator.estimateContextTokens(messages); + expect(estimate.tokens).toBe(estimator.estimateMessages(messages)); + expect(estimate.tokens).toBeLessThan(100); + expect(estimate.lastUsageIndex).toBeNull(); + expect(messages[1].usage.totalTokens).toBe(180_100); + }); + + it("uses fresh legacy usage and estimates only its trailing messages", () => { + const messages = [summary(), assistant(), assistant(2100, 21), user()]; + expect(estimator.estimateContextTokens(messages)).toMatchObject({ + tokens: 2100 + estimator.estimateMessage(messages[3]!), + usageTokens: 2100, + lastUsageIndex: 2, + }); + }); + + it.each([undefined, NaN, 20])( + "rejects legacy usage with ambiguous timestamp %s", + (timestamp) => { + const retained = { ...assistant(), timestamp } as AgentMessage; + expect( + estimator.estimateContextTokens([summary(), retained]).lastUsageIndex, + ).toBeNull(); + }, + ); + + it("does not trust any usage when the latest legacy summary lacks a timestamp", () => { + const messages = [ + assistant(), + { role: "compactionSummary", summary: "Restored." }, + assistant(2100, 30), + ]; + expect( + estimator.estimateContextTokens(messages as AgentMessage[]) + .lastUsageIndex, + ).toBeNull(); + }); + + it("leaves uncompacted usage and unsuccessful-response filtering unchanged", () => { + const messages = [ + user(), + assistant(2100), + { ...assistant(), stopReason: "error" as const }, + { ...assistant(), stopReason: "aborted" as const }, + ]; + expect(estimator.estimateContextTokens(messages)).toMatchObject({ + usageTokens: 2100, + lastUsageIndex: 1, + tokens: 2100 + estimator.estimateMessages(messages.slice(2)), + }); + }); + + it("uses the latest summary boundary and counts an unanchored trailing window", () => { + const messages = [ + summary(1), + assistant(), + { ...summary(20), keptMessageCount: 2 }, + user(), + assistant(180_100, 50), + { ...assistant(2000, 51), stopReason: "error" as const }, + user(), + ]; + expect(estimator.estimateContextTokens(messages)).toEqual({ + tokens: estimator.estimateMessages(messages), + usageTokens: 0, + trailingTokens: estimator.estimateMessages(messages), + lastUsageIndex: null, + }); + messages.push(assistant(2100, 20), user()); + expect(estimator.estimateContextTokens(messages)).toMatchObject({ + tokens: 2100 + estimator.estimateMessage(messages[8]!), + lastUsageIndex: 7, + }); + }); + + it.each([-1, 0.5, Infinity, 100])( + "falls back locally for an unusable retained count %s", + (keptMessageCount) => { + const messages = [ + { ...summary(), keptMessageCount }, + assistant(2100, 30), + ]; + expect( + estimator.estimateContextTokens(messages).lastUsageIndex, + ).toBeNull(); + }, + ); + + it("recounts replacements, skips the next checkpoint, and retains historical usage across restore", async () => { + const generateSummary = vi.fn(async () => "Small summary."); + const committed = vi.fn(); + const options = { + settings: { keepRecentTokens: 1 }, + summaryGenerator: { generateSummary }, + nowMs: () => 5, + observer: { onCompactionCommitted: committed }, + }; + const original = history(); + const before = structuredClone(original); + const decision = await new ContextManager(options).checkpoint( + input(original), + ); + expect(decision.type).toBe("replaceMessages"); + if (decision.type !== "replaceMessages") + throw new Error("Expected compaction"); + expect(decision.metadata.tokensBefore).toBe(180_100); + expect(decision.metadata.tokensAfter).toBe( + estimator.estimateMessages(decision.messages), + ); + expect(decision.metadata.tokensAfter).toBeLessThan(100); + expect(committed).toHaveBeenCalledWith( + expect.objectContaining({ tokensAfter: decision.metadata.tokensAfter }), + ); + expect(original).toEqual(before); + expect(decision.messages[1]).toEqual(original[5]); + expect(decision.metadata.keptMessages).toEqual([original[5]]); + + const restored: AgentMessage[] = JSON.parse( + JSON.stringify(decision.messages), + ); + const manager = new ContextManager(options); + // Enough messages for trigger evaluation, with no new provider usage yet. + const next = await manager.checkpoint(input([...restored, user(), user()])); + expect(next).toEqual({ + type: "skip", + reason: "context_manager_below_threshold", + }); + expect(generateSummary).toHaveBeenCalledTimes(1); + // Explicit provenance also works when the clock moved backwards or ties the summary. + expect( + estimator.estimateContextTokens([...restored, assistant(2100, 5)]), + ).toMatchObject({ + usageTokens: 2100, + lastUsageIndex: 2, + }); + expect( + estimator.estimateContextTokens([...restored, assistant(2100, 1)]), + ).toMatchObject({ + usageTokens: 2100, + lastUsageIndex: 2, + }); + }); + + it("invalidates fresh usage again on a subsequent compaction", async () => { + const manager = new ContextManager({ + settings: { keepRecentTokens: 1 }, + summaryGenerator: { generateSummary: async () => "Small summary." }, + nowMs: () => 20, + }); + const first = await manager.checkpoint(input(history())); + if (first.type !== "replaceMessages") + throw new Error("Expected compaction"); + const second = await manager.checkpoint( + input([...first.messages, ...history()]), + ); + if (second.type !== "replaceMessages") + throw new Error("Expected second compaction"); + expect(second.metadata.tokensBefore).toBe(180_100); + expect(second.metadata.tokensAfter).toBeLessThan(100); + expect( + estimator.estimateContextTokens(second.messages).lastUsageIndex, + ).toBeNull(); + }); + + it("preserves the remote counter decision and payload after compaction", async () => { + const countContextTokens = vi + .fn() + .mockResolvedValueOnce({ tokens: 190_000, source: "remote_count_tokens" }) + .mockResolvedValueOnce({ tokens: 40, source: "remote_count_tokens" }); + const onTriggerEvaluated = vi.fn(); + const manager = new ContextManager({ + settings: { keepRecentTokens: 1 }, + tokenCounter: { countContextTokens }, + observer: { onTriggerEvaluated }, + summaryGenerator: { generateSummary: async () => "Small summary." }, + }); + const decision = await manager.checkpoint(input(history())); + if (decision.type !== "replaceMessages") + throw new Error("Expected compaction"); + expect(decision.metadata.tokensBefore).toBe(190_000); + expect(decision.metadata.tokensAfter).toBeLessThan(100); + const nextInput = { + ...input([...decision.messages, user(), user()]), + apiKey: "synthetic-test-key", + headers: { "x-test": "synthetic" }, + systemPrompt: "Test system.", + tools: [], + }; + expect(await manager.checkpoint(nextInput)).toEqual({ + type: "skip", + reason: "context_manager_below_threshold", + }); + expect(countContextTokens).toHaveBeenLastCalledWith( + expect.objectContaining({ + messages: nextInput.messages, + apiKey: nextInput.apiKey, + headers: nextInput.headers, + systemPrompt: nextInput.systemPrompt, + tools: nextInput.tools, + }), + ); + expect(onTriggerEvaluated).toHaveBeenLastCalledWith( + expect.objectContaining({ + tokens: 40, + source: "remote_count_tokens", + shouldCompact: false, + }), + ); + }); +}); diff --git a/packages/agent-modules/permission/src/context.ts b/packages/agent-modules/permission/src/context.ts index 0e90d421b..d04a06c9b 100644 --- a/packages/agent-modules/permission/src/context.ts +++ b/packages/agent-modules/permission/src/context.ts @@ -224,6 +224,7 @@ function cloneContextWith( trustedExactWritePaths: ctx.trustedExactWritePaths, dataDir: ctx.dataDir, homeDir: ctx.homeDir, + isFile: ctx.isFile, agentName: ctx.agentName, sessionId: ctx.sessionId, allowManagedPermissionRulesOnly: ctx.allowManagedPermissionRulesOnly, diff --git a/packages/agent-modules/permission/src/tools/bash-permission.ts b/packages/agent-modules/permission/src/tools/bash-permission.ts index 6fa897de2..5c3274b93 100644 --- a/packages/agent-modules/permission/src/tools/bash-permission.ts +++ b/packages/agent-modules/permission/src/tools/bash-permission.ts @@ -1133,13 +1133,21 @@ function evaluateSingleSubcommand( // -------- COMMON LAYER -------- + // Every rule behavior sees the same raw and transparent-wrapper-stripped + // forms. Otherwise a wrapped command could skip deny/ask and match allow. + const wrapperStrippedCommand = stripTransparentWrappersForRuleMatch(command); + const matchesUserRule = (parsedRule: CompiledShellPermissionRule | undefined): boolean => + !!parsedRule && + (matchShellRule(command, parsedRule) || + (!!wrapperStrippedCommand && matchShellRule(wrapperStrippedCommand, parsedRule))); + // Step 1: user deny rule for (const { rule, parsedRule } of bashRules) { if (rule.ruleBehavior !== 'deny') continue; if (!rule.ruleValue.ruleContent) { return logLayer('user-deny', { verdict: 'deny', reason: { type: 'rule', rule } }); } - if (parsedRule && matchShellRule(command, parsedRule)) { + if (matchesUserRule(parsedRule)) { return logLayer('user-deny', { verdict: 'deny', reason: { type: 'rule', rule } }); } } @@ -1150,7 +1158,7 @@ function evaluateSingleSubcommand( if (!rule.ruleValue.ruleContent) { return logLayer('user-ask', { verdict: 'ask', reason: { type: 'rule', rule } }); } - if (parsedRule && matchShellRule(command, parsedRule)) { + if (matchesUserRule(parsedRule)) { return logLayer('user-ask', { verdict: 'ask', reason: { type: 'rule', rule } }); } } @@ -1301,20 +1309,13 @@ function evaluateSingleSubcommand( }); } - // Step 5: user allow rule. Match against the original command first; - // if a transparent wrapper prefixes the line (nohup/setsid/timeout/xargs/...), - // also match the stripped form so `pnpm:*` covers `nohup pnpm install`. - // Privilege wrappers (sudo / bash -c / env K=V) are not stripped. - const wrapperStrippedCommand = stripTransparentWrappersForRuleMatch(command); + // Step 5: user allow rule, using the same command forms as deny/ask above. for (const { rule, parsedRule } of bashRules) { if (rule.ruleBehavior !== 'allow') continue; if (!rule.ruleValue.ruleContent) { return logLayer('user-allow', { verdict: 'allow', reason: { type: 'rule', rule } }); } - if ( - (parsedRule && matchShellRule(command, parsedRule)) || - (wrapperStrippedCommand && parsedRule && matchShellRule(wrapperStrippedCommand, parsedRule)) - ) { + if (matchesUserRule(parsedRule)) { return logLayer('user-allow', { verdict: 'allow', reason: { type: 'rule', rule } }); } } diff --git a/packages/agent-modules/permission/src/tools/fs-checker.ts b/packages/agent-modules/permission/src/tools/fs-checker.ts index 61ef671f4..47259584f 100644 --- a/packages/agent-modules/permission/src/tools/fs-checker.ts +++ b/packages/agent-modules/permission/src/tools/fs-checker.ts @@ -53,7 +53,11 @@ export class FsToolPermissionChecker implements ToolPermissionChecker { } // Extract file path from input - const filePath = extractFilePath(input); + const filePath = + extractFilePath(input) ?? + (toolName === 'grep' || toolName === 'glob' || toolName === 'list' + ? context.workingDirectory + : undefined); if (!filePath) return undefined; const decision = evaluatePathCapability(filePath, toolActionForFsTool(toolName), context, { @@ -109,9 +113,9 @@ function toolActionForFsTool(toolName: string): 'read' | 'write' { /** Extract the primary file path from a tool's input object. */ function extractFilePath(input: Record): string | undefined { - if (typeof input.file_path === 'string') return input.file_path; - if (typeof input.filePath === 'string') return input.filePath; - if (typeof input.path === 'string') return input.path; - if (typeof input.pattern === 'string') return input.pattern; + if (typeof input.file_path === 'string' && input.file_path) return input.file_path; + if (typeof input.filePath === 'string' && input.filePath) return input.filePath; + if (typeof input.path === 'string' && input.path) return input.path; + // A search pattern is not a filesystem path. Directory tools default to cwd. return undefined; } diff --git a/packages/agent-modules/permission/src/tools/fs-permission.ts b/packages/agent-modules/permission/src/tools/fs-permission.ts index 7abcd860d..a31dca6a1 100644 --- a/packages/agent-modules/permission/src/tools/fs-permission.ts +++ b/packages/agent-modules/permission/src/tools/fs-permission.ts @@ -650,6 +650,54 @@ export function isTempDirectory(resolvedPath: string): boolean { // Internal whitelist paths // --------------------------------------------------------------------------- +/** Resolve existing parents too, so a directory alias cannot hide a protected read. */ +function resolveExistingPath(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + const parent = path.dirname(filePath); + return parent === filePath + ? filePath + : path.join(resolveExistingPath(parent), path.basename(filePath)); + } +} + +/** Runtime state is private; only designated agent assets get implicit read access. */ +function isProtectedRuntimeRead( + filePath: string, + context: PathCheckContext, + recursive: boolean, +): boolean { + const within = (target: string, root: string) => { + const relative = path.relative(root, target); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ); + }; + const roots = [context.dataDir, path.join(context.homeDir ?? os.homedir(), '.minimax')]; + const canonicalPath = resolveExistingPath(filePath); + for (const root of roots) { + if (!root) continue; + const resolvedRoot = path.resolve(root); + const canonicalRoot = resolveExistingPath(resolvedRoot); + if (recursive && within(canonicalRoot, canonicalPath)) return true; + if (!within(filePath, resolvedRoot) && !within(canonicalPath, canonicalRoot)) continue; + // Do not resolve the asset subdirectory itself: a skills/ symlink pointing + // at credentials must not turn its destination into a trusted asset root. + const readableAsset = + within(canonicalPath, path.join(canonicalRoot, 'skills')) || + (context.agentName != null && + within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace'))) || + isInternalWhitelistedPath(canonicalPath, { + ...context, + dataDir: canonicalRoot, + }); + if (!readableAsset) return true; + } + return false; +} + /** * Check if a path is in the internal whitelist (always writable by the system). * @@ -835,6 +883,24 @@ export function isPathAllowed( } } + // Runtime state can contain credentials and session data. Check before ALL + // implicit allowances, including workspace/temp and internal aliases. A grep + // of the data root must be reviewed just like a direct config.yaml read. + if ( + READ_ONLY_TOOLS.has(toolName) && + isProtectedRuntimeRead(resolved, context, toolName === 'grep') + ) { + return { + allowed: false, + reason: { + type: 'safetyCheck', + description: `Private runtime data access requires approval. Path: ${resolved}`, + category: 'credentialFile', + classifierApprovable: false, + }, + }; + } + // Step 2: Internal path whitelist if (isInternalWhitelistedPath(resolved, context)) { return { diff --git a/packages/agent-modules/permission/src/tools/path-capability.ts b/packages/agent-modules/permission/src/tools/path-capability.ts index c63840d4b..18a8f9b41 100644 --- a/packages/agent-modules/permission/src/tools/path-capability.ts +++ b/packages/agent-modules/permission/src/tools/path-capability.ts @@ -292,6 +292,7 @@ export function evaluatePathCapability( behavior: 'ask', reason: result.reason, bypassImmune: wouldBeBypassImmune, + ...(result.reason.classifierApprovable === false ? { skipAutoClassifier: true } : {}), ...buildPathRuleSuggestion(filePath, action, context), }; } diff --git a/packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts b/packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts new file mode 100644 index 000000000..a1ca0fee0 --- /dev/null +++ b/packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts @@ -0,0 +1,112 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +import { applyPermissionUpdate, createToolPermissionContext } from '../../../src/context.js'; +import { BashToolPermissionChecker } from '../../../src/tools/bash-checker.js'; +import { evaluateBashStatic } from '../../../src/tools/bash-permission.js'; +import type { PermissionBehavior, PermissionRule } from '../../../src/types.js'; + +function bashRule(behavior: PermissionBehavior, content?: string): PermissionRule { + return { + source: 'session', + ruleBehavior: behavior, + ruleValue: { toolName: 'bash', ruleContent: content }, + }; +} + +describe.each(['default', 'auto', 'bypass'] as const)('bash rule precedence in %s mode', (mode) => { + const commands = [ + 'npm publish', + 'nohup npm publish', + 'timeout 30 npm publish', + 'nice -n 5 npm publish', + 'setsid npm publish', + 'xargs npm publish', + 'nohup timeout 30 npm publish', + 'CI=1 npm publish', + 'env CI=1 npm publish', + 'nohup /usr/bin/npm publish', + 'echo ready && nohup npm publish', + ]; + + describe.each(['deny', 'ask'] as const)('%s before allow', (behavior) => { + it.each(commands)('enforces the restricted prefix for %s', (command) => { + const rules = [bashRule('allow', 'npm:*'), bashRule(behavior, 'npm publish:*')]; + expect(evaluateBashStatic(command, rules, undefined, mode).verdict).toBe(behavior); + }); + + it.each(['npm publish', 'npm pub*'])('matches stripped exact/wildcard rule %s', (content) => { + const rules = [bashRule('allow', 'npm:*'), bashRule(behavior, content)]; + expect(evaluateBashStatic('nohup npm publish', rules, undefined, mode).verdict).toBe( + behavior, + ); + }); + + it('still matches raw wrapper rules', () => { + const rules = [bashRule('allow', 'npm:*'), bashRule(behavior, 'nohup npm:*')]; + expect(evaluateBashStatic('nohup npm publish', rules, undefined, mode).verdict).toBe( + behavior, + ); + }); + + it('still matches whole-tool rules', () => { + const rules = [bashRule('allow', 'npm:*'), bashRule(behavior)]; + expect(evaluateBashStatic('nohup npm publish', rules, undefined, mode).verdict).toBe( + behavior, + ); + }); + }); + + it('allows an unrestricted wrapped command in the same prefix family', () => { + const rules = [bashRule('allow', 'npm:*'), bashRule('deny', 'npm publish:*')]; + expect(evaluateBashStatic('nohup npm install', rules, undefined, mode).verdict).toBe('allow'); + }); + + it('keeps deny stronger than ask regardless of rule order', () => { + const rules = [ + bashRule('ask', 'npm:*'), + bashRule('allow', 'npm:*'), + bashRule('deny', 'npm publish:*'), + ]; + expect(evaluateBashStatic('nohup npm publish', rules, undefined, mode).verdict).toBe('deny'); + }); +}); + +describe('permission context rule updates', () => { + it.each(['addRules', 'replaceRules', 'removeRules'] as const)( + '%s preserves local-script classification and the host file probe', + (type) => { + const workingDirectory = path.resolve('synthetic-workspace'); + const isFile = vi.fn((file: string) => file === path.join(workingDirectory, 'build.js')); + const ctx = createToolPermissionContext({ + workingDirectory, + dataDir: path.join(workingDirectory, 'data'), + homeDir: path.join(workingDirectory, 'home'), + platform: 'linux', + shellFamily: 'posix', + isFile, + rules: [{ source: 'session', ruleBehavior: 'allow', ruleValue: { toolName: 'read' } }], + }); + const checker = new BashToolPermissionChecker(); + const input = { command: 'echo ready && node ./build.js' }; + const before = checker.checkPermissions('bash', input, ctx); + expect(isFile).toHaveBeenCalledWith(path.join(workingDirectory, 'build.js')); + isFile.mockClear(); + + const updated = applyPermissionUpdate(ctx, { + type, + source: 'session', + destination: 'session', + behavior: 'allow', + rules: [{ toolName: 'read' }], + }); + expect(checker.checkPermissions('bash', input, updated)).toEqual(before); + expect(isFile).toHaveBeenCalledWith(path.join(workingDirectory, 'build.js')); + expect(updated.isFile).toBe(isFile); + expect(updated).not.toBe(ctx); + expect(Object.isFrozen(updated)).toBe(true); + expect(Object.isFrozen(updated.rules)).toBe(true); + expect(ctx.rules).toHaveLength(1); + }, + ); +}); diff --git a/packages/agent-tools/src/shared/replace-all-edit.test.ts b/packages/agent-tools/src/shared/replace-all-edit.test.ts new file mode 100644 index 000000000..1f466b899 --- /dev/null +++ b/packages/agent-tools/src/shared/replace-all-edit.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalEditTool } from '../desktop/local-pi-tools.js'; +import { buildReplaceAllEdit } from './replace-all-edit.js'; + +const replacements = ['$$', '$&', '$`', "$'"]; + +describe('literal edit replacement text', () => { + let directory: string; + let file: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'literal-edit-')); + file = join(directory, 'example.txt'); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it.each(replacements)( + 'builds a literal full-file replacement for %s', + async (replacement) => { + const original = 'before TOKEN after\nTOKEN\n'; + await writeFile(file, original); + + const result = await buildReplaceAllEdit(file, 'TOKEN', replacement); + + expect(result).toEqual({ + path: file, + edits: [ + { + oldText: original, + newText: `before ${replacement} after\n${replacement}\n`, + }, + ], + replacedCount: 2, + }); + expect(await readFile(file, 'utf8')).toBe(original); + }, + ); + + describe.each([true, false])('LocalEditTool with replace_all=%s', (replaceAll) => { + it.each(replacements)('writes %s literally to disk', async (replacement) => { + await writeFile( + file, + replaceAll ? 'before TOKEN after\nTOKEN\n' : 'before TOKEN after\n', + ); + + const result = await new LocalEditTool(directory).execute( + { sessionId: 'literal-edit-session', turnId: 'literal-edit-turn' }, + { + file_path: 'example.txt', + old_string: 'TOKEN', + new_string: replacement, + replace_all: replaceAll, + }, + ); + + expect(await readFile(file, 'utf8')).toBe( + replaceAll + ? `before ${replacement} after\n${replacement}\n` + : `before ${replacement} after\n`, + ); + expect(result.text).toContain('Successfully replaced'); + }); + }); +}); diff --git a/packages/agent-tools/src/shared/replace-all-edit.ts b/packages/agent-tools/src/shared/replace-all-edit.ts index aa9b9ecc8..ba786e950 100644 --- a/packages/agent-tools/src/shared/replace-all-edit.ts +++ b/packages/agent-tools/src/shared/replace-all-edit.ts @@ -93,7 +93,8 @@ export async function buildReplaceAllEdit( */ function rawStringReplaceAll(haystack: string, needle: string, replacement: string): string { if (typeof haystack.replaceAll === 'function') { - return haystack.replaceAll(needle, replacement); + // A callback keeps replacement text literal instead of expanding $ sequences. + return haystack.replaceAll(needle, () => replacement); } return haystack.split(needle).join(replacement); } diff --git a/packages/config/src/byok-config.ts b/packages/config/src/byok-config.ts index 595ffeb05..99b9c1bcd 100644 --- a/packages/config/src/byok-config.ts +++ b/packages/config/src/byok-config.ts @@ -4,10 +4,11 @@ // (they are part of the Config surface); the back edge here is type-only, so // there is no runtime import cycle. -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; +import { restrictConfigFileSync, writePrivateConfigFileSync } from './private-config-file.js'; import type { CustomProvidersConfig, @@ -243,10 +244,9 @@ export function migrateLegacyByokProvidersOnDisk( writeLegacyMigrationMarker(raw, marker); try { const backupPath = backupConfigForMigration(configPath); - fs.writeFileSync( + writePrivateConfigFileSync( configPath, yaml.dump(raw, { indent: 2, lineWidth: -1, noRefs: true }), - 'utf-8', ); return { migrated: true, migratedProviders, backupPath }; } catch (err) { @@ -474,12 +474,43 @@ function rewriteNexusModelProvider( return true; } +/** Repair old backups without making archival maintenance a config-load dependency. */ +export function restrictLegacyByokBackups(configPath: string): void { + if (process.platform === 'win32') return; + const directory = path.dirname(configPath); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + console.warn( + `[config] Could not inspect legacy BYOK backups in ${JSON.stringify(directory)}. ` + + 'Backup permissions were not verified; check directory access and restrict backup permissions manually.', + ); + return; + } + for (const entry of entries) { + if (entry.isFile() && entry.name.startsWith(LEGACY_BYOK_BACKUP_PREFIX)) { + const backupPath = path.join(directory, entry.name); + try { + restrictConfigFileSync(backupPath); + } catch { + // Do not expose arbitrary error messages that could contain config content. + console.warn( + `[config] Could not restrict legacy BYOK backup ${JSON.stringify(backupPath)}. ` + + 'It may still be readable by other users; restrict its permissions manually.', + ); + } + } + } +} + function backupConfigForMigration(configPath: string): string { const backupPath = path.join( path.dirname(configPath), - `${LEGACY_BYOK_BACKUP_PREFIX}${Date.now()}`, + `${LEGACY_BYOK_BACKUP_PREFIX}${Date.now()}.${randomUUID()}`, ); - fs.copyFileSync(configPath, backupPath); + restrictConfigFileSync(configPath); + writePrivateConfigFileSync(backupPath, fs.readFileSync(configPath), true); return backupPath; } diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 2fe6e6f7b..10939fde9 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -3,6 +3,7 @@ import { type RunawayGuardSettings, } from "./runaway-guard-config.js"; import fs from "node:fs"; +import { restrictConfigFileSync, writePrivateConfigFileSync } from "./private-config-file.js"; import path from "node:path"; import os from "node:os"; import { spawnSync } from "node:child_process"; @@ -13,6 +14,7 @@ import { applyManagedMinimaxContextLimits, applyRequiredProviderOverrides, migrateLegacyByokProvidersOnDisk, + restrictLegacyByokBackups, normalizeLegacyThinkingEfforts, parseCustomProvidersConfig, parseMinimaxApiConfig, @@ -1649,10 +1651,9 @@ function syncManagedPresetBaseUrl(configPath: string): void { return; (options as Record).baseURL = presetBaseURL; - fs.writeFileSync( + writePrivateConfigFileSync( configPath, yaml.dump(raw, { indent: 2, lineWidth: -1, noRefs: true }), - "utf-8", ); } @@ -1830,7 +1831,8 @@ function ensureConfigFile(): void { const defaultDataDir = resolveDataDir({ homeDir: os.homedir() }); const defaultConfigPath = path.join(defaultDataDir, "config.yaml"); if (configPath !== defaultConfigPath && fs.existsSync(defaultConfigPath)) { - fs.copyFileSync(defaultConfigPath, configPath); + restrictConfigFileSync(defaultConfigPath); + writePrivateConfigFileSync(configPath, fs.readFileSync(defaultConfigPath), true); } return; } @@ -1841,7 +1843,7 @@ function ensureConfigFile(): void { provider: managedPresetBaseUrlSyncEnabled ? preset.provider : undefined, defaultModel: preset.defaultModel, }); - fs.writeFileSync(configPath, content, "utf-8"); + writePrivateConfigFileSync(configPath, content, true); } function readConfigFile(configPath = getConfigPath()): Record { @@ -1897,6 +1899,8 @@ export function setManagedPresetBaseUrlSyncEnabled(enabled: boolean): void { } export function prepareConfigFileForRead(configPath: string): void { + restrictConfigFileSync(configPath); + restrictLegacyByokBackups(configPath); if (legacyByokProviderMigrationEnabled) { migrateLegacyByokProvidersOnDisk( configPath, diff --git a/packages/config/src/cu-backend-io.ts b/packages/config/src/cu-backend-io.ts index dc5523841..49d8668cb 100644 --- a/packages/config/src/cu-backend-io.ts +++ b/packages/config/src/cu-backend-io.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; +import { writePrivateConfigFileSync } from './private-config-file.js'; import { getConfig, getConfigPath, resetConfig } from './config.js'; import { type CuBackend, @@ -51,7 +52,7 @@ export function setCuBackend(value: CuBackend): void { raw.cuBackend = value; fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, yaml.dump(raw), 'utf-8'); + writePrivateConfigFileSync(configPath, yaml.dump(raw)); resetConfig(); } diff --git a/packages/config/src/local-model-provider-write.ts b/packages/config/src/local-model-provider-write.ts index fa521527f..8a19a428d 100644 --- a/packages/config/src/local-model-provider-write.ts +++ b/packages/config/src/local-model-provider-write.ts @@ -229,6 +229,7 @@ async function withLockedConfig( try { await fs.promises.mkdir(dirname(configPath), { recursive: true }); await fs.promises.writeFile(configPath, '', { flag: 'a', mode: LOCAL_CONFIG_FILE_MODE }); + await fs.promises.chmod(configPath, LOCAL_CONFIG_FILE_MODE); release = await lockfile.lock(configPath, { stale: 10_000, retries: { retries: 20, factor: 1, minTimeout: 5, maxTimeout: 25 }, @@ -257,26 +258,24 @@ async function withLockedConfig( async function atomicWriteFile(filePath: string, content: string): Promise { const tmpPath = join(dirname(filePath), `.config-tmp-${randomBytes(6).toString('hex')}`); + let created = false; try { - const mode = await readFilePermissionMode(filePath); - await fs.promises.writeFile(tmpPath, content, { encoding: 'utf-8', mode }); - await fs.promises.chmod(tmpPath, mode); + const mode = LOCAL_CONFIG_FILE_MODE; + const temporary = await fs.promises.open(tmpPath, 'wx', mode); + created = true; + try { + await temporary.writeFile(content, 'utf-8'); + await temporary.chmod(mode); + } finally { + await temporary.close(); + } await fs.promises.rename(tmpPath, filePath); } catch { - await fs.promises.unlink(tmpPath).catch(() => undefined); + if (created) await fs.promises.unlink(tmpPath).catch(() => undefined); throw new LocalModelProviderConfigWriteError(); } } -async function readFilePermissionMode(filePath: string): Promise { - try { - return (await fs.promises.stat(filePath)).mode & 0o777; - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') return LOCAL_CONFIG_FILE_MODE; - throw error; - } -} - function readLocalRawConfig(configPath: string): Record { if (!existsSync(configPath)) return {}; try { @@ -337,10 +336,6 @@ function assertSafeConfigRecord(record: Record): void { } } -function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && 'code' in error; -} - function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/config/src/private-config-file.ts b/packages/config/src/private-config-file.ts new file mode 100644 index 000000000..82e4f0907 --- /dev/null +++ b/packages/config/src/private-config-file.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; + +/** Config documents and their copies can contain plaintext credentials. */ +export const PRIVATE_CONFIG_FILE_MODE = 0o600; + +/** Remove non-owner access without changing an owner's read-only policy. */ +export function restrictConfigFileSync(filePath: string): void { + if (process.platform === "win32") return; + const mode = fs.statSync(filePath).mode; + // Already-private files may live on read-only mounts or be immutable. + if ((mode & 0o077) === 0) return; + fs.chmodSync(filePath, mode & 0o700); +} + +/** Restrict access before truncating or writing any secret-bearing content. */ +export function writePrivateConfigFileSync( + filePath: string, + content: string | Buffer, + exclusive = false, +): void { + const fd = fs.openSync( + filePath, + exclusive ? "wx" : "a", + PRIVATE_CONFIG_FILE_MODE, + ); + try { + fs.fchmodSync(fd, PRIVATE_CONFIG_FILE_MODE); + fs.ftruncateSync(fd, 0); + fs.writeFileSync(fd, content); + } finally { + fs.closeSync(fd); + } +} diff --git a/packages/config/src/tui-status-line-write.ts b/packages/config/src/tui-status-line-write.ts index d799366e8..e24f54b06 100644 --- a/packages/config/src/tui-status-line-write.ts +++ b/packages/config/src/tui-status-line-write.ts @@ -11,10 +11,12 @@ export async function writeTuiStatusLineSetting( ): Promise { const configPath = join(dataDir, 'config.yaml'); let temporaryPath: string | undefined; + let temporaryCreated = false; let release: (() => Promise) | undefined; try { await fs.mkdir(dataDir, { recursive: true }); await fs.writeFile(configPath, '', { flag: 'a', mode: 0o600 }); + await fs.chmod(configPath, 0o600); // Replace the real file, preserving any symlink used to manage profile settings. // Keep the temporary file on the target filesystem so rename remains atomic. const targetPath = await fs.realpath(configPath); @@ -33,12 +35,15 @@ export async function writeTuiStatusLineSetting( if (items === undefined) delete nextTui.statusLine; else nextTui.statusLine = [...items]; const next = { ...document, tui: nextTui }; - const mode = (await fs.stat(targetPath)).mode & 0o777; - await fs.writeFile(temporaryPath, yaml.dump(next, { lineWidth: -1, noRefs: true }), { - encoding: 'utf8', - mode, - }); - await fs.chmod(temporaryPath, mode); + const mode = 0o600; + const temporary = await fs.open(temporaryPath, 'wx', mode); + temporaryCreated = true; + try { + await temporary.writeFile(yaml.dump(next, { lineWidth: -1, noRefs: true }), 'utf8'); + await temporary.chmod(mode); + } finally { + await temporary.close(); + } await fs.rename(temporaryPath, targetPath); } catch { // Parser errors can contain unrelated credentials from the config source. @@ -46,7 +51,7 @@ export async function writeTuiStatusLineSetting( 'Unable to save status line settings. Check config.yaml syntax and permissions.', ); } finally { - if (temporaryPath) await fs.unlink(temporaryPath).catch(() => undefined); + if (temporaryCreated && temporaryPath) await fs.unlink(temporaryPath).catch(() => undefined); await release?.().catch(() => undefined); } } diff --git a/packages/config/test/config-file-permissions.test.ts b/packages/config/test/config-file-permissions.test.ts new file mode 100644 index 000000000..bc22d6549 --- /dev/null +++ b/packages/config/test/config-file-permissions.test.ts @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getConfig, + getConfigPath, + resetConfig, + setLegacyByokProviderMigrationEnabled, + setManagedPresetBaseUrlSyncEnabled, +} from "../src/config.js"; +import { updateLocalByokConfig } from "../src/local-model-provider-write.js"; +import { writeTuiStatusLineSetting } from "../src/tui-status-line-write.js"; +import { setCuBackend } from "../src/cu-backend-io.js"; +import { writePrivateConfigFileSync } from "../src/private-config-file.js"; +import { migrateLegacyByokProvidersOnDisk } from "../src/byok-config.js"; +import { + updateLocalConfigFile, + updateLocalByokConfig as updateLegacyByok, +} from "../../local-runtime/src/config/update.js"; + +const secret = "synthetic-config-permissions-key"; +const document = `custom_provider:\n example:\n options:\n apiKey: ${secret}\n models: {}\n`; +let root: string; +let dataDir: string; +let configPath: string; + +// Windows chmod does not express an owner-only ACL; these assertions are POSIX only. +describe.skipIf(process.platform === "win32")( + "credential-bearing config permissions", + () => { + beforeEach(() => { + root = fs.mkdtempSync(join(os.tmpdir(), "mcode-config-permissions-")); + dataDir = join(root, "profile"); + fs.mkdirSync(dataDir); + vi.spyOn(os, "homedir").mockReturnValue(root); + vi.stubEnv("MINIMAX_DATA_DIR", dataDir); + vi.stubEnv("__MAVIS_RUNTIME_MANAGED", "0"); + resetConfig(); + setLegacyByokProviderMigrationEnabled(false); + setManagedPresetBaseUrlSyncEnabled(false); + configPath = getConfigPath(); + expect(configPath).toBe(join(dataDir, "config.yaml")); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + resetConfig(); + setLegacyByokProviderMigrationEnabled(true); + setManagedPresetBaseUrlSyncEnabled(true); + fs.rmSync(root, { recursive: true, force: true }); + }); + + const writers = [ + [ + "BYOK", + () => + updateLocalByokConfig((draft) => { + draft.minimax_api = { apiKey: secret }; + }), + ], + [ + "legacy BYOK", + () => + updateLegacyByok((draft) => { + draft.minimax_api = { apiKey: secret }; + }), + ], + [ + "ordinary setting", + () => updateLocalConfigFile({ permissionMode: "default" }), + ], + ["status line", () => writeTuiStatusLineSetting(dataDir, ["model"])], + ["CU backend", () => setCuBackend("native")], + ] as const; + + it.each(writers)("%s creates a private config", async (_name, write) => { + await write(); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + + it.each(writers)( + "%s repairs an existing public config and preserves credentials", + async (_name, write) => { + fs.writeFileSync(configPath, document); + fs.chmodSync(configPath, 0o644); + await write(); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(configPath, "utf8")).toContain(secret); + }, + ); + + it.each(writers.slice(0, 4))( + "%s keeps the old file and temporary credentials private on rename failure", + async (_name, write) => { + fs.writeFileSync(configPath, document); + fs.chmodSync(configPath, 0o644); + let temporaryMode: number | undefined; + vi.spyOn(fs.promises, "rename").mockImplementation(async (source) => { + temporaryMode = fs.statSync(source).mode & 0o777; + throw new Error("synthetic rename failure"); + }); + await expect(write()).rejects.toThrow(); + expect(temporaryMode).toBe(0o600); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(configPath, "utf8")).toBe(document); + expect(fs.readdirSync(dataDir)).toEqual(["config.yaml"]); + }, + ); + + it.each(writers.slice(0, 4))( + "%s aborts before mutation if permissions cannot be restricted", + async (_name, write) => { + fs.writeFileSync(configPath, document); + vi.spyOn(fs.promises, "chmod").mockRejectedValue( + new Error("synthetic chmod failure"), + ); + await expect(write()).rejects.toThrow(); + expect(fs.readFileSync(configPath, "utf8")).toBe(document); + expect(fs.readdirSync(dataDir)).toEqual(["config.yaml"]); + }, + ); + + it("repairs an existing config when loaded without an edit", () => { + fs.writeFileSync(configPath, document); + fs.chmodSync(configPath, 0o644); + getConfig(); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(configPath, "utf8")).toBe(document); + }); + + it.each([0o400, 0o600])("loads an already-private %i config without chmod", (mode) => { + fs.writeFileSync(configPath, document, { mode }); + const chmod = vi.spyOn(fs, "chmodSync").mockImplementation(() => { + throw new Error("synthetic read-only filesystem"); + }); + expect(() => getConfig()).not.toThrow(); + expect(chmod).not.toHaveBeenCalled(); + expect(fs.statSync(configPath).mode & 0o777).toBe(mode); + }); + + it("removes non-owner access without granting owner write permission", () => { + fs.writeFileSync(configPath, document); + fs.chmodSync(configPath, 0o440); + getConfig(); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o400); + }); + + it("still rejects loading an unsafe main config when chmod fails", () => { + fs.writeFileSync(configPath, document); + fs.chmodSync(configPath, 0o644); + vi.spyOn(fs, "chmodSync").mockImplementation(() => { + throw new Error("synthetic chmod failure"); + }); + expect(() => getConfig()).toThrow("synthetic chmod failure"); + expect(fs.readFileSync(configPath, "utf8")).toBe(document); + }); + + it.skipIf(process.platform !== "darwin")("loads immutable private config and backup files", () => { + const backup = join(dataDir, "config.yaml.bak.byok-legacy-provider.immutable"); + const files = [configPath, backup]; + try { + for (const file of files) { + fs.writeFileSync(file, document, { mode: 0o400 }); + const result = spawnSync("chflags", ["uchg", file], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + } + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => getConfig()).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); + for (const file of files) expect(fs.statSync(file).mode & 0o777).toBe(0o400); + } finally { + for (const file of files) spawnSync("chflags", ["nouchg", file]); + } + }); + + it("creates managed defaults privately", () => { + vi.stubEnv("__MAVIS_RUNTIME_MANAGED", "1"); + getConfig(); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + + it("copies default-profile credentials into a private config", () => { + const defaults = join(root, ".minimax"); + fs.mkdirSync(defaults); + fs.writeFileSync(join(defaults, "config.yaml"), document, { + mode: 0o644, + }); + getConfig(); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(configPath, "utf8")).toBe(document); + }); + + it("repairs old migration backups even when migration is disabled", () => { + fs.writeFileSync(configPath, document); + const backup = join(dataDir, "config.yaml.bak.byok-legacy-provider.123"); + fs.writeFileSync(backup, document); + fs.chmodSync(backup, 0o644); + getConfig(); + expect(fs.statSync(backup).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(backup, "utf8")).toBe(document); + }); + + it("warns on backup repair failure, loads the config and repairs remaining backups", () => { + fs.writeFileSync(configPath, document, { mode: 0o600 }); + const failed = join(dataDir, "config.yaml.bak.byok-legacy-provider.1"); + const repaired = join(dataDir, "config.yaml.bak.byok-legacy-provider.2"); + for (const backup of [failed, repaired]) { + fs.writeFileSync(backup, document); + fs.chmodSync(backup, 0o644); + } + const chmod = fs.chmodSync; + vi.spyOn(fs, "chmodSync").mockImplementation((file, mode) => { + if (file === failed) throw new Error(secret); + chmod(file, mode); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => getConfig()).not.toThrow(); + expect(fs.statSync(failed).mode & 0o777).toBe(0o644); + expect(fs.statSync(repaired).mode & 0o777).toBe(0o600); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(JSON.stringify(failed))); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("restrict its permissions manually")); + expect(JSON.stringify(warn.mock.calls)).not.toContain(secret); + }); + + it("warns when backup inspection fails without blocking the main config", () => { + fs.writeFileSync(configPath, document, { mode: 0o600 }); + vi.spyOn(fs, "readdirSync").mockImplementation(() => { + throw new Error(secret); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => getConfig()).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(JSON.stringify(dataDir))); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Backup permissions were not verified")); + expect(JSON.stringify(warn.mock.calls)).not.toContain(secret); + }); + + it("keeps a symlinked status-line config and its credentials private", async () => { + const target = join(root, "linked.yaml"); + fs.writeFileSync(target, document); + fs.chmodSync(target, 0o644); + fs.symlinkSync(target, configPath); + await writeTuiStatusLineSetting(dataDir, ["model"]); + expect(fs.lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(fs.statSync(target).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(target, "utf8")).toContain(secret); + }); + + it("restricts synchronous copies before writing their first credential byte", () => { + const write = fs.writeFileSync; + const modes: number[] = []; + vi.spyOn(fs, "writeFileSync").mockImplementation((file, ...args) => { + if (typeof file === "number") + modes.push(fs.fstatSync(file).mode & 0o777); + return write(file, ...args); + }); + writePrivateConfigFileSync(configPath, document); + expect(modes).toEqual([0o600]); + }); + + it("does not truncate or write credentials when restriction fails", () => { + fs.writeFileSync(configPath, "original"); + vi.spyOn(fs, "fchmodSync").mockImplementation(() => { + throw new Error("synthetic permission failure"); + }); + expect(() => writePrivateConfigFileSync(configPath, document)).toThrow( + "synthetic permission failure", + ); + expect(fs.readFileSync(configPath, "utf8")).toBe("original"); + }); + + it("creates a private migration backup and repairs the migrated config", () => { + fs.writeFileSync( + configPath, + `provider:\n legacy:\n options:\n apiKey: ${secret}\n baseURL: https://example.invalid/v1\n models: {}\n`, + ); + fs.chmodSync(configPath, 0o644); + const result = migrateLegacyByokProvidersOnDisk(configPath, { + isManagedRuntime: () => true, + shouldEnforceManagedProviderProtection: () => false, + getManagedPreset: () => ({ provider: {}, defaultModel: "" }), + isManagedPresetBaseUrl: () => false, + }); + expect(result.migrated).toBe(true); + for (const file of [configPath, result.backupPath!]) { + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(file, "utf8")).toContain(secret); + } + }); + }, +); diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/list-models.test.ts b/packages/local-runtime-v2/src/service/model-system/catalog/list-models.test.ts index 641cab264..e732ff344 100644 --- a/packages/local-runtime-v2/src/service/model-system/catalog/list-models.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/catalog/list-models.test.ts @@ -274,6 +274,29 @@ describe('model catalog entry helpers', () => { }); }); + it('exposes Kimi K3 low/high/max effort options in the model roster', () => { + const entry = buildModelEntry({ + providerId: 'custom_provider:moonshotai', + modelId: 'kimi-k3', + model: { + name: 'Kimi K3', + reasoning: true, + thinking: { effortOptions: ['low', 'high', 'max'] }, + }, + selected: false, + providerSource: 'custom_provider', + providerKind: 'custom', + providerName: 'Moonshot AI', + }); + + expect(entry).toMatchObject({ + modelId: 'kimi-k3', + displayName: 'Kimi K3', + effortOptions: ['low', 'high', 'max'], + }); + expect(entry.defaultEffort).toBeUndefined(); + }); + it('exposes context option hints only for managed models', () => { const model = { limit: { context: 512_000 }, diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts index 39e729121..c47ed0c89 100644 --- a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts @@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { providerCompletionUrl, providerModelsUrls } from '../../connectivity/provider-request.js'; import { ProviderPresetCatalog } from './provider-presets.service.js'; import { readProviderPresetSnapshotCandidates } from './provider-presets.repository.js'; +import { modelsFromInputs } from '../../management/service-input.js'; +import { buildModelEntry } from '../list-models.js'; vi.mock('./provider-presets.repository.js', async (importOriginal) => { const actual = await importOriginal(); @@ -396,6 +398,117 @@ describe('models.dev Provider Presets', () => { }, ]); }); + + it('parses declared reasoning effort options for Kimi K3 on both Moonshot providers', async () => { + const k3 = { + name: 'Kimi K3', + tool_call: true, + attachment: true, + reasoning: true, + reasoning_options: [ + { type: 'toggle' }, + { type: 'effort', values: ['low', 'high', 'max'] }, + ], + temperature: false, + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + limit: { context: 1_048_576, output: 131_072 }, + }; + const presets = await parsePresetsForTest({ + moonshotai: { + name: 'Moonshot AI', + npm: '@ai-sdk/openai-compatible', + api: 'https://api.moonshot.ai/v1', + models: { + 'kimi-k3': k3, + 'kimi-k2.6': { + name: 'Kimi K2.6', + tool_call: true, + reasoning: true, + reasoning_options: [{ type: 'toggle' }], + }, + 'kimi-k2.7-code': { + name: 'Kimi K2.7 Code', + tool_call: true, + reasoning: true, + reasoning_options: [], + }, + }, + }, + 'moonshotai-cn': { + name: 'Moonshot AI China', + npm: '@ai-sdk/openai-compatible', + api: 'https://api.moonshot.cn/v1', + models: { 'kimi-k3': k3 }, + }, + }); + + for (const providerId of ['moonshotai', 'moonshotai-cn'] as const) { + const preset = presets.find((candidate) => candidate.providerId === providerId); + expect(preset?.models.find((model) => model.modelId === 'kimi-k3')).toMatchObject({ + reasoning: true, + effortOptions: ['low', 'high', 'max'], + }); + if (!preset) throw new Error(`Missing preset ${providerId}`); + const savedModel = modelsFromInputs(preset.models)['kimi-k3']; + expect(savedModel?.thinking).toEqual({ effortOptions: ['low', 'high', 'max'] }); + expect(buildModelEntry({ + providerId: `custom_provider:${providerId}`, + modelId: 'kimi-k3', + model: savedModel, + selected: false, + providerSource: 'custom_provider', + providerKind: 'custom', + providerName: preset.name, + }).effortOptions).toEqual(['low', 'high', 'max']); + } + const moonshot = presets.find((candidate) => candidate.providerId === 'moonshotai'); + // A toggle-only or bare reasoning model can think, but declares no + // selectable effort levels, so it must not gain effortOptions. + for (const modelId of ['kimi-k2.6', 'kimi-k2.7-code'] as const) { + const model = moonshot?.models.find((candidate) => candidate.modelId === modelId); + expect(model).toMatchObject({ reasoning: true }); + expect(model).not.toHaveProperty('effortOptions'); + } + }); + + it('normalizes effort metadata without inventing or dropping declared levels', async () => { + const [preset] = await parsePresetsForTest({ + compatible: { + name: 'Compatible API', + npm: '@ai-sdk/openai-compatible', + api: 'https://api.example.test/v1', + models: { + custom: { + name: 'Custom', + tool_call: true, + reasoning: true, + reasoning_options: [null, {}, { type: 'effort', values: 'high' }, + { type: 'effort', values: [' light ', 'light', '', ' ', 7, null, 'max'] }], + }, + 'empty-effort': { + name: 'Empty effort', + tool_call: true, + reasoning: true, + reasoning_options: [{ type: 'effort', values: [] }], + }, + 'budget-only': { + name: 'Budget only', + tool_call: true, + reasoning: true, + reasoning_options: [{ type: 'budget_tokens', min: 128, max: 32_768 }], + }, + }, + }, + }); + + const model = (modelId: string) => + preset?.models.find((candidate) => candidate.modelId === modelId); + // Unknown levels are preserved verbatim; blanks, duplicates and + // non-strings never reach the roster. + expect(model('custom')).toMatchObject({ effortOptions: ['light', 'max'] }); + expect(model('empty-effort')).not.toHaveProperty('effortOptions'); + expect(model('budget-only')).not.toHaveProperty('effortOptions'); + }); }); describe('models.dev Provider Preset snapshots', () => { diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts index 306c140d7..5c86fa5bb 100644 --- a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts +++ b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts @@ -177,6 +177,7 @@ function parseModel(modelId: string, value: unknown): UserModelInputView | undef modelId, displayName: stringValue(value.name) ?? modelId, ...parseModelCapabilities(value), + ...parseModelEffortOptions(value), ...parseModelModalities(value), ...parseModelLimit(value), }; @@ -191,6 +192,33 @@ function parseModelCapabilities(value: Record): Partial): Partial { + if (!Array.isArray(value.reasoning_options)) return {}; + for (const option of value.reasoning_options) { + if (!isRecord(option) || option.type !== 'effort' || !Array.isArray(option.values)) continue; + const effortOptions: string[] = []; + const seen = new Set(); + for (const candidate of option.values) { + if (typeof candidate !== 'string') continue; + const effort = candidate.trim(); + if (!effort || seen.has(effort)) continue; + seen.add(effort); + effortOptions.push(effort); + } + if (effortOptions.length > 0) return { effortOptions }; + } + return {}; +} + function parseModelModalities(value: Record): Partial { const modalities = isRecord(value.modalities) ? value.modalities : undefined; const input = stringArray(modalities?.input); diff --git a/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts b/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts index 5d6e9b68d..278831f3c 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts @@ -97,6 +97,23 @@ describe('model provider input normalization', () => { }); }); + it('persists declared Kimi K3 effort options as thinking metadata', () => { + expect( + modelsFromInputs([ + { + modelId: 'kimi-k3', + displayName: 'Kimi K3', + reasoning: true, + effortOptions: ['low', 'high', 'max'], + }, + ])['kimi-k3'], + ).toEqual({ + name: 'Kimi K3', + reasoning: true, + thinking: { effortOptions: ['low', 'high', 'max'] }, + }); + }); + it('applies implicit thinking only to eligible new models', () => { expect(modelsFromInputs([{ modelId: 'implicit' }], undefined, true).implicit).toMatchObject({ reasoning: true, diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts index 9df19555a..6138755a0 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts @@ -403,6 +403,32 @@ describe('BYOK Think Effort selection', () => { }); }); + it.each(['low', 'high', 'max'] as const)( + 'carries the Kimi K3 effort %s from catalog metadata to the OpenAI-compatible request patch', + (effort) => { + // What the models.dev preset chain persists for moonshotai/kimi-k3: + // reasoning_options { type: 'effort', values: ['low', 'high', 'max'] } + // becomes thinking.effortOptions on the stored model config. + const config: LocalModelConfig = { + name: 'Kimi K3', + reasoning: true, + thinking: { effortOptions: ['low', 'high', 'max'] }, + }; + const ref = modelRefForModel('custom_provider:moonshotai', 'kimi-k3', config, { + thinking: { effort }, + }); + + const selectedEffort = readSelectedThinkingEffort(ref.capabilities); + expect(selectedEffort).toBe(effort); + expect( + resolveByokThinkingProtocol('openai-completions', selectedEffort, ref.model_id), + ).toMatchObject({ + effort, + requestPatch: { reasoning_effort: effort }, + }); + }, + ); + it.each([`${DEFAULT_THINKING_MODEL_IDS[0]}[1m]`, ...DEFAULT_THINKING_MODEL_IDS.slice(1)])( 'omits thinking for default-thinking model %s while preserving effort', (modelId) => { diff --git a/packages/local-runtime/src/config/update.ts b/packages/local-runtime/src/config/update.ts index 3973cdb6d..21882e86f 100644 --- a/packages/local-runtime/src/config/update.ts +++ b/packages/local-runtime/src/config/update.ts @@ -81,6 +81,7 @@ export async function updateLocalConfigFile( try { await fs.promises.mkdir(dirname(configPath), { recursive: true }); await fs.promises.writeFile(configPath, '', { flag: 'a', mode: LOCAL_CONFIG_FILE_MODE }); + await fs.promises.chmod(configPath, LOCAL_CONFIG_FILE_MODE); release = await lockfile.lock(configPath, { stale: 10_000, retries: { retries: 20, factor: 1, minTimeout: 5, maxTimeout: 25 }, @@ -138,6 +139,7 @@ export async function compareAndSetLocalModelContext( try { await fs.promises.mkdir(dirname(configPath), { recursive: true }); await fs.promises.writeFile(configPath, '', { flag: 'a', mode: LOCAL_CONFIG_FILE_MODE }); + await fs.promises.chmod(configPath, LOCAL_CONFIG_FILE_MODE); release = await lockfile.lock(configPath, { stale: 10_000, retries: { retries: 20, factor: 1, minTimeout: 5, maxTimeout: 25 }, @@ -229,6 +231,7 @@ export async function updateLocalByokConfig( try { await fs.promises.mkdir(dirname(configPath), { recursive: true }); await fs.promises.writeFile(configPath, '', { flag: 'a', mode: LOCAL_CONFIG_FILE_MODE }); + await fs.promises.chmod(configPath, LOCAL_CONFIG_FILE_MODE); release = await lockfile.lock(configPath, { stale: 10_000, retries: { retries: 20, factor: 1, minTimeout: 5, maxTimeout: 25 }, @@ -288,26 +291,24 @@ export async function updateLocalByokConfig( export async function atomicWriteFile(filePath: string, content: string): Promise { const tmpPath = join(dirname(filePath), `.config-tmp-${randomBytes(6).toString('hex')}`); + let created = false; try { - const mode = await readFilePermissionMode(filePath); - await fs.promises.writeFile(tmpPath, content, { encoding: 'utf-8', mode }); - await fs.promises.chmod(tmpPath, mode); + const mode = LOCAL_CONFIG_FILE_MODE; + const temporary = await fs.promises.open(tmpPath, 'wx', mode); + created = true; + try { + await temporary.writeFile(content, 'utf-8'); + await temporary.chmod(mode); + } finally { + await temporary.close(); + } await fs.promises.rename(tmpPath, filePath); } catch { - await fs.promises.unlink(tmpPath).catch(() => undefined); + if (created) await fs.promises.unlink(tmpPath).catch(() => undefined); throw new LocalConfigWriteError(); } } -async function readFilePermissionMode(filePath: string): Promise { - try { - return (await fs.promises.stat(filePath)).mode & 0o777; - } catch (err) { - if (isNodeError(err) && err.code === 'ENOENT') return LOCAL_CONFIG_FILE_MODE; - throw err; - } -} - function readLocalRawConfig(configPath: string): Record { if (!existsSync(configPath)) return {}; try { @@ -476,10 +477,6 @@ function toLocalConfigError(err: unknown): Error { return new LocalConfigWriteError(); } -function isNodeError(err: unknown): err is NodeJS.ErrnoException { - return err instanceof Error && 'code' in err; -} - function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/local-runtime/src/permissions/facade.ts b/packages/local-runtime/src/permissions/facade.ts index fa33795b6..a16a8fac0 100644 --- a/packages/local-runtime/src/permissions/facade.ts +++ b/packages/local-runtime/src/permissions/facade.ts @@ -977,51 +977,7 @@ export class LocalPermissionFacade { // Helpers // ------------------------------------------------------------------------- - /** - * Self-paths that mavis auto-allows for READ-ONLY tools without - * prompting. **Write-side tools always go through the normal review - * flow even on these paths.** - * - * Rationale: mavis's own runtime data (`cfg.dataDir`) and the user-wide - * default dataDir (``) hold the runtime's own assets — built-in - * skills (`/skills//SKILL.md`), session transcripts, - * agent configs, generated hooks. The agent READING those is not - * "external filesystem access" in any meaningful sense; it is the - * runtime reading its own application directory. Prompting the user - * for "may the agent read its own SKILL.md?" is noise on par with an - * OS asking "may VS Code read its own settings.json?". - * - * **Writes are deliberately NOT auto-allowed.** The sensitive-file - * safeguards in fs-permission (`isCredentialFile`, `isEnvFile`, - * `isSensitiveGitFile`, `isSystemSensitivePath`) only run on the - * READ side (`fs-permission.ts:818-875` lives under the read-policy - * path). On the WRITE side, `sandboxAllowPaths` would short-circuit - * to allow at `fs-permission.ts:968` BEFORE any credential / .env / - * .git/config check could intervene — letting a default/auto-mode - * agent silently create or overwrite - * `/.aws/credentials`, `/.env`, or - * `/.git/config`. By only injecting the self-paths for - * read-only tools (set parity with `READ_ONLY_TOOLS` = - * `{read, glob, grep, list}`), writes fall through to the normal - * ask flow — the user sees a card for every mavis-internal write, - * which keeps the sensitive-write attack surface unchanged. - * - * What this still does NOT bypass on the read side: - * - `isSystemSensitivePath` / `isCredentialFile` / `isEnvFile` / - * `isInDangerousDirectory + isSensitiveGitFile` checks - * (`fs-permission.ts:818-875`) all run BEFORE the sandbox-allow - * branch (`fs-permission.ts:968`), so a stray - * `/.aws/credentials` or `/.env` still ASKs. - * - The HARD-blocked fs-read registry (ssh / aws / *.pem / Windows - * registry hive / macOS keychain) runs even earlier - * (`path-capability.ts:82`). - * - bypass-immune deny in the facade itself (UNC, root rm). - * - * The defensive filter drops a self-path if it would become a parent of - * `workingDirectory` — if a user accidentally configured their workspace - * inside `/`, we don't want the sandbox allow to silently widen - * the workspace boundary; the normal workspace check covers it. - */ + /** Built-in skill assets may be read without granting access to private runtime state. */ private resolveSandboxAllowPaths(toolName: string, workingDirectory: string): string[] { // Read-only tool set parity with `READ_ONLY_TOOLS` // (`packages/local-runtime/src/permission/tools/fs-permission.ts`). @@ -1033,8 +989,8 @@ export class LocalPermissionFacade { const candidates: string[] = []; const dataDir = this.deps.configGetter().dataDir; - if (dataDir) candidates.push(path.resolve(dataDir)); - candidates.push(path.resolve(homedir(), '.minimax')); + if (dataDir) candidates.push(path.resolve(dataDir, 'skills')); + candidates.push(path.resolve(homedir(), '.minimax', 'skills')); // De-duplicate (dataDir may already equal on a default install). const unique = Array.from(new Set(candidates)); if (!workingDirectory) return unique; @@ -1255,7 +1211,7 @@ export class LocalPermissionFacade { ` 1. Remove ${scriptPath} and restart the app — startup re-seeds the script.\n` + ' 2. If it fails again, verify the runtime data directory is writable and not ' + 'mounted noexec, and that the script keeps its execute bit.\n' + - ' 3. Two app versions sharing one data directory overwrite each other\'s script; ' + + " 3. Two app versions sharing one data directory overwrite each other's script; " + 'close the other version, then restart.', denySource: 'safety-immune', }; diff --git a/packages/local-runtime/src/turns/diff-api.ts b/packages/local-runtime/src/turns/diff-api.ts index c55da8e00..9248fc4d5 100644 --- a/packages/local-runtime/src/turns/diff-api.ts +++ b/packages/local-runtime/src/turns/diff-api.ts @@ -11,6 +11,16 @@ import { json } from '../api/host-helpers.js'; import { applyLocalTurnDiffSnapshotMutation } from './file-changes.js'; const execFile = promisify(execFileCallback); +// Read paths and metrics as NUL records; patch headers are presentation only. +const WORKSPACE_DIFF_ARGS = [ + '--raw', + '--numstat', + '-z', + '--patch', + '--no-color', + '--no-ext-diff', + '--no-textconv', +]; export interface LocalDiffSession { readonly sessionId: string; @@ -238,22 +248,19 @@ async function readWorkspaceDiff( workspaceDir: string, ): Promise<{ fileChanges: LocalFileDiff[]; rawDiff?: string }> { try { - const [nameStatus, numstat, rawDiff] = await Promise.all([ - execGit(['diff', 'HEAD', '--name-status', '--'], workspaceDir), - execGit(['diff', 'HEAD', '--numstat', '--'], workspaceDir), - execGit(['diff', 'HEAD', '--'], workspaceDir), - ]); + const result = await execGit(['diff', 'HEAD', ...WORKSPACE_DIFF_ARGS, '--'], workspaceDir); + const trackedDiffs = parseGitDiffSummary(result.stdout); const untracked = await readUntrackedFiles(workspaceDir); const untrackedDiffs = await Promise.all( untracked.map((file) => readUntrackedFileDiff(workspaceDir, file)), ); - const raw = [rawDiff.stdout, ...untrackedDiffs.flatMap((item) => item.rawDiff ?? [])] + const raw = [ + ...trackedDiffs.flatMap((item) => item.diff ?? []), + ...untrackedDiffs.flatMap((item) => item.rawDiff ?? []), + ] .filter(Boolean) .join('\n'); - const fileChanges = [ - ...parseGitDiffSummary(nameStatus.stdout, numstat.stdout, rawDiff.stdout), - ...untrackedDiffs.map((item) => item.fileChange), - ]; + const fileChanges = [...trackedDiffs, ...untrackedDiffs.map((item) => item.fileChange)]; return { fileChanges, ...(raw ? { rawDiff: raw } : {}) }; } catch { return { fileChanges: [] }; @@ -268,48 +275,59 @@ async function execGit(args: string[], cwd: string): Promise<{ stdout: string; s }); } -function parseGitDiffSummary( - nameStatus: string, - numstat: string, - rawDiff: string, -): LocalFileDiff[] { +function parseGitDiffSummary(output: string): LocalFileDiff[] { + if (!output) return []; + // --raw --numstat -z --patch emits raw records, then numstat records, + // then an extra NUL before the patches. Git uses the same order for raw + // records and patch blocks, including rename-only and binary changes. + const boundary = output.indexOf('\0\0'); + if (boundary < 0) throw new Error('Git diff is missing its patch boundary'); + const records = output.slice(0, boundary).split('\0'); + const files: Array<{ file: string; status: string }> = []; + let index = 0; + while (records[index]?.startsWith(':')) { + const status = records[index++]!.split(' ').at(-1)!; + const source = records[index++]; + const file = /^[RC]/u.test(status) ? records[index++] : source; + if (!file) throw new Error('Git diff is missing a raw path'); + files.push({ file, status: mapGitStatus(status) }); + } const stats = new Map(); - for (const line of numstat.split('\n')) { - if (!line.trim()) continue; - const [additions, deletions, ...pathParts] = line.split('\t'); - const file = pathParts.join('\t'); - if (!file) continue; + while (index < records.length) { + const [additions, deletions, ...pathParts] = records[index++]!.split('\t'); + let file = pathParts.join('\t'); + // Renames/copies have an empty numstat path followed by old and new paths. + if (!file) { + index += 1; + file = records[index++] ?? ''; + } + if (!file) throw new Error('Git diff is missing a numstat path'); stats.set(file, { additions: numericDiffStat(additions), deletions: numericDiffStat(deletions), }); } - const patches = splitPatchByFile(rawDiff); - const out: LocalFileDiff[] = []; - for (const line of nameStatus.split('\n')) { - if (!line.trim()) continue; - const [statusToken, ...pathParts] = line.split('\t'); - const file = pathParts[pathParts.length - 1]; - if (!file) continue; - const stat = stats.get(file) ?? { additions: 0, deletions: 0 }; - out.push({ - file, - additions: stat.additions, - deletions: stat.deletions, - status: mapGitStatus(statusToken), - ...(patches.get(file) ? { diff: patches.get(file) } : {}), - }); + const patches = output + .slice(boundary + 2) + .split(/(?=^diff --git )/mu) + .filter(Boolean); + if (files.length !== patches.length) { + throw new Error('Git diff raw paths and patch blocks do not match'); } - return out; + return files.map(({ file, status }, position) => { + const stat = stats.get(file); + if (!stat) throw new Error('Git diff is missing file metrics'); + return { file, status, ...stat, diff: patches[position] }; + }); } async function readUntrackedFiles(workspaceDir: string): Promise { try { - const result = await execGit(['ls-files', '--others', '--exclude-standard'], workspaceDir); - return result.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); + const result = await execGit( + ['ls-files', '--others', '--exclude-standard', '-z'], + workspaceDir, + ); + return result.stdout.split('\0').filter(Boolean); } catch { return []; } @@ -320,28 +338,24 @@ async function readUntrackedFileDiff( file: string, ): Promise<{ fileChange: LocalFileDiff; rawDiff?: string }> { try { - const rawDiff = await execFile('git', ['diff', '--no-index', '--', '/dev/null', file], { - cwd: workspaceDir, - env: cleanGitEnv(), - maxBuffer: 10 * 1024 * 1024, - }).catch((err: unknown) => { + const rawDiff = await execFile( + 'git', + ['diff', '--no-index', ...WORKSPACE_DIFF_ARGS, '--', '/dev/null', file], + { + cwd: workspaceDir, + env: cleanGitEnv(), + maxBuffer: 10 * 1024 * 1024, + }, + ).catch((err: unknown) => { const error = err as { stdout?: string; code?: number }; if (error.code === 1 && typeof error.stdout === 'string') return { stdout: error.stdout }; throw err; }); - const text = rawDiff.stdout; - const additions = text - .split('\n') - .filter((line) => line.startsWith('+') && !line.startsWith('+++')).length; + const parsed = parseGitDiffSummary(rawDiff.stdout)[0]; + if (!parsed) throw new Error('Git diff is missing an untracked file'); return { - fileChange: { - file, - additions, - deletions: 0, - status: 'added', - ...(text ? { diff: text } : {}), - }, - ...(text ? { rawDiff: text } : {}), + fileChange: { ...parsed, file, status: 'added' }, + ...(parsed.diff ? { rawDiff: parsed.diff } : {}), }; } catch { return { @@ -355,19 +369,6 @@ async function readUntrackedFileDiff( } } -function splitPatchByFile(rawDiff: string): Map { - const patches = new Map(); - const chunks = rawDiff.split(/^diff --git /m); - for (const chunk of chunks) { - if (!chunk.trim()) continue; - const text = `diff --git ${chunk}`; - const match = /^diff --git a\/(.+?) b\/(.+)$/m.exec(text); - const file = match?.[2]; - if (file) patches.set(file, text); - } - return patches; -} - function mapGitStatus(status: string | undefined): string { const code = status?.[0]; if (code === 'A') return 'added'; diff --git a/packages/local-runtime/test/unit/local-permission-facade.test.ts b/packages/local-runtime/test/unit/local-permission-facade.test.ts index ed6075af6..29ebe6500 100644 --- a/packages/local-runtime/test/unit/local-permission-facade.test.ts +++ b/packages/local-runtime/test/unit/local-permission-facade.test.ts @@ -16,12 +16,8 @@ * facade ↔ gate plumbing is exercised end-to-end. * - localHardCheck.deny (UNC, root rm) is bypass-immune across all * askPolicy and runs BEFORE the gate, so the gate cannot weaken it - * - sandbox self-paths: dataDir + ~/.minimax auto-allow ONLY for - * read-only tools (read/glob/grep/list) so reading a SKILL.md is - * friction-free; writes still go through the normal ask flow - * because fs-permission's credential/.env/.git-sensitive guards - * only run on the read side, never on writes. Even on the read - * side, sensitive files inside dataDir still ASK. + * - designated skill assets remain readable; private runtime state and + * directory-wide searches require approval, including aliases. * - rewrittenInput (rm → mavis-trash) survives the ask branch * - localeHint is derived from inline latestUserMessages * - cloud-gateway timeout / block / confirm route to ask-user with @@ -31,11 +27,15 @@ * - acceptEdits seed adds 3 global rules; idempotent */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; -import { readWindowsTrashExecution } from '@mavis/permission'; +import { + configurePermissionHost, + readWindowsTrashExecution, + resetPermissionHostForTesting, +} from '@mavis/permission'; import { describe, expect, it, vi } from 'vitest'; import type { CloudClassifyRequest, @@ -67,6 +67,7 @@ function freshFacade( workspaceDir?: string; sandbox?: LocalRuntimeConfig['sandbox']; seedPosixTrash?: boolean; + dataDirParent?: string; } = {}, ): { facade: LocalPermissionFacade; @@ -74,7 +75,7 @@ function freshFacade( pluginHookPermissionStore: LocalPluginHookPermissionStore; dataDir: string; } { - const dataDir = mkdtempSync(path.join(tmpdir(), 'aa-facade-')); + const dataDir = mkdtempSync(path.join(opts.dataDirParent ?? tmpdir(), 'aa-facade-')); const config: LocalRuntimeConfig = { dataDir, permissionMode: opts.permissionMode ?? 'default', @@ -1708,15 +1709,170 @@ describe('LocalPermissionFacade', () => { }); describe('sandbox self-paths (dataDir auto-allow)', () => { - // Reads of mavis's own runtime data (skills, sessions, agent configs) - // are not "external filesystem access" — they're the agent operating - // on its own application directory. The facade injects `cfg.dataDir` - // and `~/.minimax` into `sandboxAllowPaths`; fs-permission's - // step-5 sandbox branch then allows reads and writes of those paths - // without an ask card. + it.each(['read', 'grep', 'glob', 'list'])( + 'keeps private runtime %s requests out of automatic approval', + async (toolName) => { + const classify = vi.fn(async (): Promise => ({ + kind: 'allow', + reasonLocalized: 'ok', + })); + const { facade, dataDir } = freshFacade({ + permissionMode: 'auto', + cloudGateway: { classify }, + }); + configurePermissionHost({ + runtimeConfigProvider: { + getConfig: () => ({}), + getRuntimeRegion: () => 'cn', + getRuntimeBuildEnv: () => 'prod', + isManagedRuntime: () => true, + }, + }); + try { + const result = await facade.checkPermission({ + toolName, + input: { + path: toolName === 'read' ? path.join(dataDir, 'config.yaml') : dataDir, + pattern: '*', + }, + }); + expect(result.behavior).toBe('ask'); + expect(classify).not.toHaveBeenCalled(); + expect(result.hookAutoApprovalEligible).toBeUndefined(); + } finally { + resetPermissionHostForTesting(); + cleanup(dataDir); + } + }, + ); + + it('requires approval for aliases to credentials, including internal memory paths', async () => { + const workspaceDir = mkdtempSync(path.join(tmpdir(), 'runtime-alias-workspace-')); + const { facade, dataDir } = freshFacade({ + workspaceDir, + dataDirParent: homedir(), + }); + try { + const configPath = path.join(dataDir, 'config.yaml'); + writeFileSync(configPath, 'synthetic: fixture-only-secret\n'); + mkdirSync(path.join(dataDir, 'memory')); + mkdirSync(path.join(dataDir, 'skills')); + const aliases = [ + path.join(workspaceDir, 'notes.txt'), + path.join(dataDir, 'memory', 'notes.txt'), + path.join(dataDir, 'skills', 'notes.txt'), + ]; + for (const alias of aliases) { + symlinkSync(configPath, alias); + expect( + ( + await facade.checkPermission({ + toolName: 'read', + agentName: 'fixture-agent', + input: { path: alias }, + }) + ).behavior, + ).toBe('ask'); + } + const directoryAlias = path.join(workspaceDir, 'runtime'); + symlinkSync(dataDir, directoryAlias, 'dir'); + expect( + ( + await facade.checkPermission({ + toolName: 'grep', + agentName: 'fixture-agent', + input: { path: directoryAlias, pattern: 'synthetic' }, + }) + ).behavior, + ).toBe('ask'); + } finally { + cleanup(dataDir); + cleanup(workspaceDir); + } + }); + + it.each([true, false])('protects nested runtime data from recursive search (explicit path: %s)', async (explicitPath) => { + const workspaceDir = mkdtempSync(path.join(tmpdir(), 'runtime-parent-workspace-')); + const { facade, dataDir } = freshFacade({ + workspaceDir, + dataDirParent: workspaceDir, + }); + try { + const result = await facade.checkPermission({ + toolName: 'grep', + agentName: 'fixture-agent', + input: { ...(explicitPath ? { path: workspaceDir } : {}), pattern: 'secret' }, + }); + expect(result.behavior).toBe('ask'); + } finally { + cleanup(dataDir); + cleanup(workspaceDir); + } + }); + it('honors an explicit path approval for private runtime data', async () => { + const { facade, ruleStore, dataDir } = freshFacade({ + dataDirParent: homedir(), + }); + try { + const configPath = path.join(dataDir, 'config.yaml'); + await ruleStore.applyUpdate({ + type: 'addRules', + source: 'global', + destination: 'global', + behavior: 'allow', + rules: [{ tool_name: 'read', rule_content: configPath }], + }); + expect( + ( + await facade.checkPermission({ + toolName: 'read', + input: { path: configPath }, + }) + ).behavior, + ).toBe('allow'); + } finally { + cleanup(dataDir); + } + }); + it('keeps the active agent workspace readable when it is stored under runtime data', async () => { + const { facade, dataDir } = freshFacade({ dataDirParent: homedir() }); + try { + const result = await facade.checkPermission({ + toolName: 'read', + agentName: 'fixture-agent', + input: { path: path.join(dataDir, 'agents', 'fixture-agent', 'workspace', 'report.txt') }, + }); + expect(result.behavior).toBe('allow'); + } finally { + cleanup(dataDir); + } + }); + it.each([tmpdir(), homedir()])( + 'requires approval before reading the runtime credential configuration under %s', + async (dataDirParent) => { + const { facade, dataDir } = freshFacade({ + permissionMode: 'default', + workspaceDir: '/synthetic-workspace', + dataDirParent, + }); + try { + writeFileSync(path.join(dataDir, 'config.yaml'), 'synthetic: fixture-only-secret\n'); + const result = await facade.checkPermission({ + toolName: 'read', + input: { path: path.join(dataDir, 'config.yaml') }, + }); + expect(result.behavior).toBe('ask'); + } finally { + cleanup(dataDir); + } + }, + ); it('default mode: read of a SKILL.md inside dataDir auto-allows (no card)', async () => { - const { facade, dataDir } = freshFacade({ permissionMode: 'default' }); + const { facade, dataDir } = freshFacade({ + permissionMode: 'default', + dataDirParent: homedir(), + }); try { const r = await facade.checkPermission({ toolName: 'read', @@ -1786,12 +1942,12 @@ describe('LocalPermissionFacade', () => { } }); - it('glob inside dataDir auto-allows (read-only sibling of read)', async () => { + it('glob inside the skill assets auto-allows', async () => { const { facade, dataDir } = freshFacade({ permissionMode: 'default' }); try { const r = await facade.checkPermission({ toolName: 'glob', - input: { path: dataDir, pattern: 'skills/*/SKILL.md' }, + input: { path: path.join(dataDir, 'skills'), pattern: '*/SKILL.md' }, }); // glob/grep/list share the read-only tool set with `read`; they // should auto-allow on self-paths for the same reason. diff --git a/packages/local-runtime/test/unit/session-diff-fallback.test.ts b/packages/local-runtime/test/unit/session-diff-fallback.test.ts new file mode 100644 index 000000000..85c259df2 --- /dev/null +++ b/packages/local-runtime/test/unit/session-diff-fallback.test.ts @@ -0,0 +1,303 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, appendFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { closeLocalRuntimeDb } from '../../src/persistence/db.js'; +import { SqliteLocalTurnDiffStore } from '../../src/persistence/sqlite-persistence.js'; +import { readLocalSessionDiff } from '../../src/turns/diff-api.js'; + +let root: string; +let workspaceDir: string; +let dataDir: string; +let diffStore: SqliteLocalTurnDiffStore; + +function git(...args: string[]): string { + return execFileSync('git', args, { cwd: workspaceDir, encoding: 'utf8' }); +} + +async function write(file: string, content: string | Buffer): Promise { + await mkdir(path.dirname(path.join(workspaceDir, file)), { recursive: true }); + await writeFile(path.join(workspaceDir, file), content); +} + +function commit(): void { + git('add', '--all'); + git('commit', '-qm', 'Synthetic baseline'); +} + +async function read(messageId?: string) { + return readLocalSessionDiff({ + diffStore, + session: { sessionId: 'test-session', workspaceDir }, + messageId, + }); +} + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'session-diff-')); + workspaceDir = path.join(root, 'workspace'); + dataDir = path.join(root, 'data'); + await mkdir(workspaceDir); + // Keep user hooks, attributes, diff drivers and quoting defaults out of fixtures. + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1'); + vi.stubEnv('GIT_CONFIG_GLOBAL', path.join(root, 'no-global-config')); + for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX']) { + vi.stubEnv(key, undefined); + } + git('init', '-q'); + git('config', 'user.name', 'Diff Test'); + git('config', 'user.email', 'diff-test@example.com'); + git('config', 'core.autocrlf', 'false'); + git('config', 'diff.renames', 'true'); + await write('baseline.txt', 'unchanged\n'); + commit(); + diffStore = new SqliteLocalTurnDiffStore(dataDir); +}); + +afterEach(async () => { + closeLocalRuntimeDb(dataDir); + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); +}); + +const portableNames = ['中文.txt', 'with spaces.txt', 'dir/{before after}.txt']; +const posixNames = [ + 'tab\tname.txt', + 'line\nname.txt', + 'literal => arrow.txt', + ' quote"slash\\.txt', + ' trailing.txt ', +]; + +describe.each(['true', 'false'])('session diff fallback with core.quotePath=%s', (quotePath) => { + beforeEach(() => { + git('config', 'core.quotePath', quotePath); + }); + + it('preserves Unicode paths and attaches each tracked patch through the public API', async () => { + for (const file of portableNames) await write(file, 'first\n'); + commit(); + for (const [index, file] of portableNames.entries()) { + await appendFile(path.join(workspaceDir, file), `added-${index}\n`); + } + const result = await read(); + expect(result.diffs).toHaveLength(portableNames.length); + for (const [index, file] of portableNames.entries()) { + expect(result.diffs.find((entry) => entry.file === file)).toMatchObject({ + file, + additions: 1, + deletions: 0, + status: 'modified', + diff: expect.stringContaining(`+added-${index}\n`), + }); + } + }); + + it('reports rename edits against the destination path', async () => { + await write('before.txt', 'a\nb\nc\nd\ne\nf\n'); + commit(); + git('mv', 'before.txt', 'after.txt'); + await appendFile(path.join(workspaceDir, 'after.txt'), 'added\n'); + expect((await read('missing-message')).diffs).toEqual([ + { + file: 'after.txt', + additions: 1, + deletions: 0, + status: 'renamed', + diff: expect.stringContaining('+added\n'), + }, + ]); + }); + + it('keeps additions, deletions, binary changes and untracked paths distinct', async () => { + await write('deleted 中文.txt', 'remove\n'); + await write('binary.bin', Buffer.from([0, 1, 2])); + commit(); + await rm(path.join(workspaceDir, 'deleted 中文.txt')); + await write('binary.bin', Buffer.from([0, 3, 4])); + await write('staged 中文.txt', 'staged\n'); + git('add', '--', 'staged 中文.txt'); + for (const file of portableNames) await write(file, 'untracked\n++content\n'); + await write('untracked.bin', Buffer.from([0, 5, 6])); + await write('empty.txt', ''); + const { diffs } = await read(); + expect(diffs).toHaveLength(portableNames.length + 5); + expect(diffs.find((entry) => entry.file === 'deleted 中文.txt')).toMatchObject({ + additions: 0, + deletions: 1, + status: 'deleted', + diff: expect.stringContaining('-remove\n'), + }); + expect(diffs.find((entry) => entry.file === 'staged 中文.txt')).toMatchObject({ + additions: 1, + deletions: 0, + status: 'added', + diff: expect.stringContaining('+staged\n'), + }); + for (const file of ['binary.bin', 'untracked.bin']) { + expect(diffs.find((entry) => entry.file === file)).toMatchObject({ + additions: 0, + deletions: 0, + diff: expect.stringContaining('Binary files '), + }); + } + for (const file of portableNames) { + expect(diffs.find((entry) => entry.file === file)).toMatchObject({ + additions: 2, + deletions: 0, + status: 'added', + diff: expect.stringContaining('+++content\n'), + }); + } + expect(diffs.find((entry) => entry.file === 'empty.txt')).toMatchObject({ + additions: 0, + deletions: 0, + status: 'added', + }); + }); + + it.skipIf(process.platform === 'win32')( + 'preserves POSIX whitespace, escape and arrow names for tracked and untracked files', + async () => { + for (const file of posixNames) await write(file, 'first\n'); + commit(); + for (const [index, file] of posixNames.entries()) { + await appendFile(path.join(workspaceDir, file), `tracked-${index}\n`); + await write(`untracked/${file}`, `untracked-${index}\n`); + } + const { diffs } = await read(); + expect(diffs).toHaveLength(posixNames.length * 2); + for (const [index, file] of posixNames.entries()) { + expect(diffs.find((entry) => entry.file === file)).toMatchObject({ + additions: 1, + deletions: 0, + diff: expect.stringContaining(`+tracked-${index}\n`), + }); + expect(diffs.find((entry) => entry.file === `untracked/${file}`)).toMatchObject({ + additions: 1, + deletions: 0, + diff: expect.stringContaining(`+untracked-${index}\n`), + }); + } + }, + ); + + it('retains a rename-only patch with zero line changes', async () => { + await write('old 中文.txt', 'same\n'); + commit(); + git('mv', 'old 中文.txt', 'new 中文.txt'); + expect((await read()).diffs).toEqual([ + { + file: 'new 中文.txt', + additions: 0, + deletions: 0, + status: 'renamed', + diff: expect.stringContaining('similarity index 100%'), + }, + ]); + }); + + it.skipIf(process.platform === 'win32')( + 'consumes both rename paths without splitting tabs or literal arrows', + async () => { + const source = 'dir/old\tline\n{left => right}.txt'; + const destination = 'dir/new\tline\n{right => left}.txt'; + await write(source, 'a\nb\nc\nd\ne\nf\n'); + commit(); + git('mv', source, destination); + await appendFile(path.join(workspaceDir, destination), 'renamed\n'); + expect((await read()).diffs).toEqual([ + { + file: destination, + additions: 1, + deletions: 0, + status: 'renamed', + diff: expect.stringContaining('+renamed\n'), + }, + ]); + }, + ); + + it('handles brace-compressed renames and copies with destination stats and patches', async () => { + await write('dir/old 中文.txt', 'a\nb\nc\nd\ne\nf\n'); + await write('copy-source.txt', 'unique\ncopy\nsource\nlines\nhere\nend\n'); + commit(); + git('mv', 'dir/old 中文.txt', 'dir/new 中文.txt'); + await appendFile(path.join(workspaceDir, 'dir/new 中文.txt'), 'renamed\n'); + await appendFile(path.join(workspaceDir, 'copy-source.txt'), 'source-edit\n'); + await write('copy 中文.txt', 'unique\ncopy\nsource\nlines\nhere\nend\ncopied\n'); + git('add', '--', 'copy 中文.txt'); + git('config', 'diff.renames', 'copies'); + const { diffs } = await read(); + expect(diffs).toHaveLength(3); + expect(diffs.find((entry) => entry.file === 'dir/new 中文.txt')).toMatchObject({ + additions: 1, + deletions: 0, + status: 'renamed', + diff: expect.stringContaining('+renamed\n'), + }); + expect(diffs.find((entry) => entry.file === 'copy 中文.txt')).toMatchObject({ + additions: 1, + deletions: 0, + status: 'copied', + diff: expect.stringContaining('+copied\n'), + }); + expect(diffs.find((entry) => entry.file === 'copy-source.txt')).toMatchObject({ + additions: 1, + deletions: 0, + status: 'modified', + diff: expect.stringContaining('+source-edit\n'), + }); + }); +}); + +it('returns an empty diff for a clean workspace', async () => { + expect(await read()).toEqual({ diffs: [] }); +}); + +it('prefers stored session/message diffs and falls back only when the selected record is absent', async () => { + await write('live.txt', 'live\n'); + const fileChanges = [{ file: 'stored.txt', additions: 7, deletions: 2, diff: 'stored patch' }]; + await diffStore.upsert({ + sessionId: 'test-session', + turnId: 'test-turn', + changeSetId: 'test-change', + assistantMessageId: 'test-message', + workspaceDir, + capturedAtMs: Date.now(), + status: 'active', + fileChanges, + }); + for (const messageId of [undefined, 'test-message']) { + expect(await read(messageId)).toEqual({ diffs: fileChanges, changeSetId: 'test-change' }); + } + expect((await read('absent-message')).diffs).toEqual([ + { + file: 'live.txt', + additions: 1, + deletions: 0, + status: 'added', + diff: expect.stringContaining('+live\n'), + }, + ]); +}); + +it('associates patches without relying on color or path prefixes', async () => { + await write('中文.txt', 'first\n'); + commit(); + await appendFile(path.join(workspaceDir, '中文.txt'), 'added\n'); + git('config', 'color.ui', 'always'); + git('config', 'diff.noprefix', 'true'); + expect((await read()).diffs).toEqual([ + { + file: '中文.txt', + additions: 1, + deletions: 0, + status: 'modified', + diff: expect.stringContaining('+added\n'), + }, + ]); +}); diff --git a/packages/tui/docs/status-line-config.md b/packages/tui/docs/status-line-config.md index d2be21bd7..9975de665 100644 --- a/packages/tui/docs/status-line-config.md +++ b/packages/tui/docs/status-line-config.md @@ -15,7 +15,7 @@ Open `/statusline` to configure ordinary items: - Both regular and fullscreen modes adapt the panel to available height. Small windows prioritize search, the selected option, and save / cancel hints. - The panel is unavailable during pending permission / question interactions and in side sessions. It may open during ordinary execution without sending a model request. -The panel manages ordinary items only. For automation, edit the current profile's `config.yaml` (default in this distribution: `~/.minimax-code/config.yaml`): +The panel manages ordinary items only. For automation, edit the current profile's `config.yaml` (default in this distribution: `~/.minimax/config.yaml`): ```yaml tui: @@ -27,7 +27,7 @@ Restart mcode after saving to enable the `[V]` line. `build-mode` is not in the ## Configuration location -- File: `config.yaml` in the runtime data directory, normally `~/.minimax-code/config.yaml`. +- File: `config.yaml` in the runtime data directory, normally `~/.minimax/config.yaml`. - Key: `tui.statusLine`, a **string array** of item IDs. - List order is display order, left to right. Items not listed are hidden. @@ -243,7 +243,7 @@ A command may ignore stdin. stdout is accepted only after exit code `0` without - A deterministic selection changes every 30-second time bucket, using existing redraws rather than a dedicated timer. An idle terminal does not redraw merely to rotate tips; the next normal redraw selects the current bucket. - Left-side composer mode and action hints remain stable. Prefer a full tip, then a short version, then hide it if space is insufficient. - Welcome, active execution, follow-ups, goals, permission / question interactions, attachments, nonempty drafts, and temporary hints take precedence and suppress tips. -- Tips are enabled by default. Disable them in `~/.minimax-code/config.yaml`: +- Tips are enabled by default. Disable them in `~/.minimax/config.yaml`: ```yaml tui: diff --git a/packages/tui/src/application/assistant-content.ts b/packages/tui/src/application/assistant-content.ts index 5a9085e7e..c4363b121 100644 --- a/packages/tui/src/application/assistant-content.ts +++ b/packages/tui/src/application/assistant-content.ts @@ -4,6 +4,7 @@ import { type DeliverAssetItem, type DeliverAssetsSegment, } from '@mavis/shared/asset-markup'; +import { sanitizeTerminalText } from '../tui/rendering/terminal-text.js'; const RICH_CONTENT_RE = /<(?:deliver[-_]assets|media|publish[_-]artifact|preview_card|genui-|mavis-widget|mavis-thinking|mavis-progress|think|final|permission-(?:ask|response)|questionnaire-(?:ask|response))\b|^\s*preview_cards?:\s*$/imu; @@ -25,6 +26,8 @@ export function simplifyAssistantContentForTerminal(content: string): string { } export function projectAssistantContentForTerminal(content: string): TerminalAssistantContent { + // Model content is data. Only the renderer may introduce terminal controls. + content = sanitizeTerminalText(content); if (!content || !RICH_CONTENT_RE.test(content)) return { text: content, assets: [] }; const segments = splitMarkdownProtectedSegments(content); diff --git a/packages/tui/src/cli/network-proxy.ts b/packages/tui/src/cli/network-proxy.ts index d7bb7413b..10f7b3402 100644 --- a/packages/tui/src/cli/network-proxy.ts +++ b/packages/tui/src/cli/network-proxy.ts @@ -44,7 +44,7 @@ export function resolveTuiProxyConfiguration( mode: 'proxy', httpProxy: httpProxy?.value ?? '', httpsProxy: httpsProxy?.value ?? '', - noProxy: withLoopbackNoProxy(environment.NO_PROXY ?? environment.no_proxy), + noProxy: withLoopbackNoProxy(firstProxyValue(environment, 'NO_PROXY', 'no_proxy')?.value), }; } diff --git a/packages/tui/src/cli/program.ts b/packages/tui/src/cli/program.ts index b8836c04a..6355ba65c 100644 --- a/packages/tui/src/cli/program.ts +++ b/packages/tui/src/cli/program.ts @@ -174,13 +174,17 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { ) .option('--model ', 'model ID (repeatable)', collectOptionValue, []) .option('--api-key-env ', 'environment variable containing the API key') - .option('--use', 'select the first model as the default') + .option('--context-limit ', 'context limit for every listed model', parsePositiveSafeInteger) + .option('--output-limit ', 'output limit for every listed model', parsePositiveSafeInteger) + .option('--use', 'test the first model, then save and select it as the default') .action( (commandOptions: { name: string; baseUrl: string; apiFormat: McodeProviderApiFormat; model: string[]; + contextLimit?: number; + outputLimit?: number; apiKeyEnv?: string; use?: boolean; }) => { @@ -193,6 +197,8 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { baseUrl: commandOptions.baseUrl, apiFormat: commandOptions.apiFormat, models: commandOptions.model, + contextLimit: commandOptions.contextLimit, + outputLimit: commandOptions.outputLimit, apiKeyEnv: commandOptions.apiKeyEnv, saveAndUse: commandOptions.use, }); @@ -392,3 +398,11 @@ function requireAcpRunner(options: CreateTuiProgramOptions) { if (!options.runAcp) throw new Error('ACP server is unavailable.'); return options.runAcp; } + +function parsePositiveSafeInteger(value: string): number { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) { + throw new InvalidArgumentError('expected a positive safe integer'); + } + return number; +} diff --git a/packages/tui/src/cli/provider-command.ts b/packages/tui/src/cli/provider-command.ts index dea098280..a3ce4623f 100644 --- a/packages/tui/src/cli/provider-command.ts +++ b/packages/tui/src/cli/provider-command.ts @@ -12,6 +12,8 @@ export type McodeProviderCliRequest = readonly baseUrl: string; readonly apiFormat: McodeProviderApiFormat; readonly models: readonly string[]; + readonly contextLimit?: number; + readonly outputLimit?: number; readonly apiKeyEnv?: string; readonly saveAndUse?: boolean; } @@ -58,14 +60,46 @@ export async function runMcodeProviderCommand( `Provider API key is missing. Set ${envName} or pass --api-key-env .`, ); } - await context.application.create({ + const input = { name: request.name, baseUrl: request.baseUrl, apiKey, apiFormat: request.apiFormat, - models: request.models.map((modelId) => ({ modelId })), - saveAndUse: request.saveAndUse, - }); + models: request.models.map((modelId) => ({ + modelId, + ...(request.contextLimit !== undefined || request.outputLimit !== undefined + ? { + limit: { + ...(request.contextLimit !== undefined ? { context: request.contextLimit } : {}), + ...(request.outputLimit !== undefined ? { output: request.outputLimit } : {}), + }, + } + : {}), + })), + }; + if (request.saveAndUse) { + const modelId = request.models[0]; + if (!modelId) throw new Error('At least one --model is required.'); + const result = await context.application.saveCandidate({ + ...input, + modelId, + saveAndUse: true, + }); + if (!result.success) { + throw new Error( + formatTuiActionFailure( + result.status?.lastErrorMessage ?? result.status?.state ?? 'Connection unavailable', + { + summary: 'Provider connection test failed. Nothing was saved or selected.', + nextStep: + 'Check the URL, API key, and first model ID, then retry; omit --use to save without testing.', + }, + ), + ); + } + return `Provider added and selected: ${request.name}`; + } + await context.application.create(input); return `Provider added: ${request.name}`; } if (request.action === 'remove') { diff --git a/packages/tui/src/provider/application.ts b/packages/tui/src/provider/application.ts index c22e497d5..c254a5444 100644 --- a/packages/tui/src/provider/application.ts +++ b/packages/tui/src/provider/application.ts @@ -248,6 +248,8 @@ function normalizeCustomProvider( modelId: model.modelId, ...(model.displayName ? { displayName: model.displayName } : {}), ...(model.selected !== undefined ? { selected: model.selected } : {}), + ...(model.contextLimit !== undefined ? { contextLimit: model.contextLimit } : {}), + ...(model.maxOutputTokens !== undefined ? { maxOutputTokens: model.maxOutputTokens } : {}), ...(model.status ? { status: model.status } : {}), })), ...(copilot && copilotOAuthStatus diff --git a/packages/tui/src/provider/contract.ts b/packages/tui/src/provider/contract.ts index a08467f0b..b59d5f81d 100644 --- a/packages/tui/src/provider/contract.ts +++ b/packages/tui/src/provider/contract.ts @@ -46,6 +46,8 @@ export interface McodeProviderModel { readonly modelId: string; readonly displayName?: string; readonly selected?: boolean; + readonly contextLimit?: number; + readonly maxOutputTokens?: number; readonly status?: McodeProviderStatus; } diff --git a/packages/tui/src/runtime/data-dir.ts b/packages/tui/src/runtime/data-dir.ts index 50799d2b5..2b07d4eb5 100644 --- a/packages/tui/src/runtime/data-dir.ts +++ b/packages/tui/src/runtime/data-dir.ts @@ -1,5 +1,4 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { getPrimaryDataDirPath, getProfile } from '@mavis/config'; import { resolveMcodeDataEnvironment, type McodeDataEnvironment } from '../auth/environment.js'; import { configureTuiRuntimeEnvironment } from '../cli/environment.js'; @@ -17,8 +16,12 @@ export interface PrepareTuiDataDirOptions { configureRuntimeEnvironment?: typeof configureTuiRuntimeEnvironment; } -export function resolveDefaultTuiDataDir(_buildEnv: McodeDataEnvironment): string { - return join(homedir(), '.minimax-code'); +export function resolveDefaultTuiDataDir( + _buildEnv: McodeDataEnvironment, + getPrimaryDataDir: typeof getPrimaryDataDirPath = getPrimaryDataDirPath, + getCurrentProfile: typeof getProfile = getProfile, +): string { + return getPrimaryDataDir(undefined, getCurrentProfile()); } function getDefaultTuiDataDir(): string { diff --git a/packages/tui/src/runtime/lifecycle.ts b/packages/tui/src/runtime/lifecycle.ts index b8720affa..e75437408 100644 --- a/packages/tui/src/runtime/lifecycle.ts +++ b/packages/tui/src/runtime/lifecycle.ts @@ -154,6 +154,8 @@ export async function createTuiRuntime( }, }; }; + // Reject unreadable or unsafe config before auth watchers can keep a failed CLI alive. + const requestedMcodeTools = getConfig().beta?.mcodeTools === true; const readAuthContext = dependencies.readAuthContext ?? readCliAuthContext; const importSharedAuthContext = dependencies.importSharedAuthContext ?? importSharedCliAuthContext; @@ -318,7 +320,6 @@ export async function createTuiRuntime( } } } - const requestedMcodeTools = getConfig().beta?.mcodeTools === true; let mcodeToolsReadiness: McodeToolsReadiness = fallbackMcodeToolsReadiness( requestedMcodeTools, authScope.buildEnv, diff --git a/packages/tui/src/tui/features/transcript/panel.ts b/packages/tui/src/tui/features/transcript/panel.ts index a02fdc474..785f164ef 100644 --- a/packages/tui/src/tui/features/transcript/panel.ts +++ b/packages/tui/src/tui/features/transcript/panel.ts @@ -15,6 +15,7 @@ import { VStack, } from '../../engine/public.js'; import { projectAssistantContentForTerminal } from '../../../application/assistant-content.js'; +import { sanitizeTerminalText } from '../../rendering/terminal-text.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; import { renderTuiStructuredPreview } from '../../transcript/presentation/structured-preview.js'; @@ -564,7 +565,9 @@ export class TuiTranscriptPanel implements TuiFeatureScreen, Component, Focusabl ): string[] { const lines: string[] = []; if (label) lines.push(this.detailLine(chalk.bold.hex(colors.dim)(label), contentWidth + 4)); - const rendered = new Text(chalk.hex(colors.muted)(value), 0, 0).render(contentWidth); + const rendered = new Text(chalk.hex(colors.muted)(sanitizeTerminalText(value)), 0, 0).render( + contentWidth, + ); lines.push(...rendered.map((line) => this.detailLine(line, contentWidth + 4))); return lines; } diff --git a/packages/tui/src/tui/rendering/terminal-text.ts b/packages/tui/src/tui/rendering/terminal-text.ts index b2e05a345..a76fe2865 100644 --- a/packages/tui/src/tui/rendering/terminal-text.ts +++ b/packages/tui/src/tui/rendering/terminal-text.ts @@ -1,8 +1,14 @@ import { stripVTControlCharacters } from 'node:util'; export function sanitizeTerminalText(value: string): string { - return stripVTControlCharacters(value).replace( - /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, + // Consume terminal strings, including incomplete streaming prefixes and C1 + // forms, before stripping ordinary ANSI styles. Their payload is not prose. + const text = value.replace( + /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)|(?:\u001b[P^_X]|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\u001b\\|\u009c|$)/gu, '', ); + return stripVTControlCharacters(text) + .replace(/\r\n/gu, '\n') + .replace(/\r/gu, ' ') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, ''); } diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 78c087bb8..869867350 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -990,7 +990,7 @@ function boundThinkingPreview(detail: string, edge: 'head' | 'tail'): string { function renderExecutionDetail(detail: string, width: number, rawPrefix = ' │ '): string[] { const prefix = chalk.hex(colors.dim)(rawPrefix); const contentWidth = Math.max(1, width - visibleLength(rawPrefix)); - return new Text(chalk.hex(colors.muted)(detail), 0, 0) + return new Text(chalk.hex(colors.muted)(sanitizeTerminalText(detail)), 0, 0) .render(contentWidth) .map((line) => truncateToWidth(`${prefix}${line}`, Math.max(0, width), '')); } diff --git a/packages/tui/src/update/install-source.ts b/packages/tui/src/update/install-source.ts index 9d10f50e5..356fd1b10 100644 --- a/packages/tui/src/update/install-source.ts +++ b/packages/tui/src/update/install-source.ts @@ -327,12 +327,40 @@ export function bindMcodeNpmCommandToRuntime( path.join(npmDirectory, 'node_modules', 'npm', 'bin', 'npm-cli.js'), path.join(npmDirectory, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), ]; - const npmCli = candidates.find((file) => existsSync(file) && statSync(file).isFile()); + const npmCli = + candidates.find((file) => existsSync(file) && statSync(file).isFile()) ?? + resolvePnpmNpmShim(npmExecutable); if (!npmCli) throw new Error(`Cannot locate npm-cli.js for the owned npm executable: ${command.executable}`); return { ...command, executable: runtimeExecutable, args: [npmCli, ...command.args] }; } +function resolvePnpmNpmShim(npmExecutable: string): string | undefined { + // pnpm env places npm behind a shell shim in PNPM_HOME. Read only its literal + // versioned CLI target; never execute/source the shim or search other runtimes. + try { + const metadata = statSync(npmExecutable); + if (!metadata.isFile() || metadata.size > 64 * 1024) return undefined; + const lines = readFileSync(npmExecutable, 'utf8').split(/\r?\n/u); + if (lines[0] !== '#!/bin/sh') return undefined; + const commands = lines.map((line) => line.trim()).filter((line) => /^exec\s/u.test(line)); + if (commands.length === 0) return undefined; + const targets = commands.map((line) => + /^exec (?:"\$basedir\/node"|node) "\$basedir\/(nodejs\/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\/lib\/node_modules\/npm\/bin\/npm-cli\.js)" "\$@"$/u.exec(line)?.[1], + ); + const target = targets[0]; + if (!target || targets.some((candidate) => candidate !== target)) return undefined; + const npmDirectory = path.dirname(npmExecutable); + const versionRoot = path.join(npmDirectory, ...target.split('/').slice(0, 2)); + const cli = realpathSync(path.join(npmDirectory, target)); + // Reject symlinks that redirect the target outside this pnpm Node version. + if (!isResolvedPathInside(versionRoot, cli, path) || !statSync(cli).isFile()) return undefined; + return cli; + } catch { + return undefined; + } +} + export function createMcodeNpmRuntimeEnvironment( environment: NodeJS.ProcessEnv, runtimeExecutable: string, diff --git a/packages/tui/test/unit/headless-model-selection.test.ts b/packages/tui/test/unit/headless-model-selection.test.ts index c95fa7782..9835b6566 100644 --- a/packages/tui/test/unit/headless-model-selection.test.ts +++ b/packages/tui/test/unit/headless-model-selection.test.ts @@ -397,3 +397,42 @@ describe('resolveHeadlessModelSelection', () => { ).resolves.toEqual({ providerId: 'provider', modelId: 'model', variant: 'fast' }); }); }); + +describe('resolveHeadlessModelSelection with Kimi K3 effort levels', () => { + const k3Runtime = () => + runtimeWith( + catalog({ + providerId: 'custom_provider:moonshotai', + modelId: 'kimi-k3', + selected: true, + effortOptions: ['low', 'high', 'max'], + }), + ); + + it.each(['low', 'high', 'max'] as const)('accepts --effort %s', async (effort) => { + await expect( + resolveHeadlessModelSelection({ + model: 'custom_provider:moonshotai/kimi-k3', + effort, + session: session(), + runtime: k3Runtime(), + }), + ).resolves.toEqual({ + providerId: 'custom_provider:moonshotai', + modelId: 'kimi-k3', + thinking: { effort }, + }); + }); + + it('rejects an effort level Kimi K3 does not declare', async () => { + await expectInvocationError( + resolveHeadlessModelSelection({ + model: 'custom_provider:moonshotai/kimi-k3', + effort: 'medium', + session: session(), + runtime: k3Runtime(), + }), + '--effort medium is not available for custom_provider:moonshotai/kimi-k3. Available levels: low, high, max.', + ); + }); +}); diff --git a/packages/tui/test/unit/network-proxy.test.ts b/packages/tui/test/unit/network-proxy.test.ts new file mode 100644 index 000000000..4f9ee7af2 --- /dev/null +++ b/packages/tui/test/unit/network-proxy.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveTuiProxyConfiguration, + shouldBypassTuiProxy, +} from '../../src/cli/network-proxy.js'; + +const PROXY_URL = 'http://proxy.example.invalid:8080'; + +describe('NO_PROXY environment precedence', () => { + it.each([undefined, '', ' \t\n '])( + 'falls back to no_proxy when NO_PROXY is %j', + (noProxy) => { + const configuration = resolveTuiProxyConfiguration({ + HTTPS_PROXY: PROXY_URL, + NO_PROXY: noProxy, + no_proxy: ' internal.example.invalid ', + }); + + expect(configuration.mode).toBe('proxy'); + if (configuration.mode !== 'proxy') throw new Error('Expected proxy configuration'); + expect( + shouldBypassTuiProxy(new URL('https://internal.example.invalid/v1'), configuration.noProxy), + ).toBe(true); + expect( + shouldBypassTuiProxy(new URL('https://external.example.invalid/v1'), configuration.noProxy), + ).toBe(false); + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + expect(shouldBypassTuiProxy(new URL(`http://${host}/`), configuration.noProxy)).toBe(true); + } + }, + ); + + it('prefers a nonblank NO_PROXY over no_proxy', () => { + const configuration = resolveTuiProxyConfiguration({ + HTTPS_PROXY: PROXY_URL, + NO_PROXY: ' upper.example.invalid ', + no_proxy: 'lower.example.invalid', + }); + + expect(configuration.mode).toBe('proxy'); + if (configuration.mode !== 'proxy') throw new Error('Expected proxy configuration'); + expect(shouldBypassTuiProxy(new URL('https://upper.example.invalid/'), configuration.noProxy)).toBe( + true, + ); + expect(shouldBypassTuiProxy(new URL('https://lower.example.invalid/'), configuration.noProxy)).toBe( + false, + ); + }); + + it.each([undefined, '', ' \t '])( + 'preserves the loopback defaults when both spellings are %j', + (noProxy) => { + const configuration = resolveTuiProxyConfiguration({ + HTTPS_PROXY: PROXY_URL, + NO_PROXY: noProxy, + no_proxy: noProxy, + }); + + expect(configuration.mode).toBe('proxy'); + if (configuration.mode !== 'proxy') throw new Error('Expected proxy configuration'); + expect(configuration.noProxy).toBe('localhost,127.0.0.1,::1'); + }, + ); + + it('stays direct when only a bypass list is configured', () => { + expect(resolveTuiProxyConfiguration({ NO_PROXY: '', no_proxy: 'internal.example.invalid' })).toEqual( + { mode: 'direct' }, + ); + }); +}); diff --git a/packages/tui/test/unit/provider-application.test.ts b/packages/tui/test/unit/provider-application.test.ts index 42daa9d1c..dfef9bcf0 100644 --- a/packages/tui/test/unit/provider-application.test.ts +++ b/packages/tui/test/unit/provider-application.test.ts @@ -40,7 +40,7 @@ function createPort() { configRevision: 'rev-1', maskedApiKey: 'sk-****1234', rawApiKey: 'must-never-cross-the-cli-boundary', - models: [{ modelId: 'gpt-4.1', displayName: 'GPT-4.1' }], + models: [{ modelId: 'gpt-4.1', displayName: 'GPT-4.1', contextLimit: 32768, maxOutputTokens: 4096 }], }, ]), getMiniMaxApiKeyStatus: vi.fn(async () => ({ @@ -196,6 +196,10 @@ describe('McodeProviderApplication', () => { expect(JSON.stringify(snapshot)).toContain('MiniMax OAuth'); expect(JSON.stringify(snapshot)).not.toContain('must-never-cross-the-cli-boundary'); expect(snapshot.providers[2]).not.toHaveProperty('rawApiKey'); + expect(snapshot.providers[2]?.models[0]).toMatchObject({ + contextLimit: 32768, + maxOutputTokens: 4096, + }); expect(port.getCodexOAuthStatus).not.toHaveBeenCalled(); }); diff --git a/packages/tui/test/unit/runtime-data-dir.test.ts b/packages/tui/test/unit/runtime-data-dir.test.ts new file mode 100644 index 000000000..c2dda75f4 --- /dev/null +++ b/packages/tui/test/unit/runtime-data-dir.test.ts @@ -0,0 +1,44 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + getTuiDataDirPath, + prepareTuiDataDir, + resolveDefaultTuiDataDir, +} from '../../src/runtime/data-dir.js'; + +describe('TUI data directory', () => { + it.each(['dev', 'test', 'staging', 'prod'] as const)( + 'uses the shared user directory for %s builds', + (buildEnv) => { + expect(resolveDefaultTuiDataDir(buildEnv, undefined, () => null)).toBe( + join(homedir(), '.minimax'), + ); + }, + ); + + it('keeps the shared profile suffix', () => { + expect(resolveDefaultTuiDataDir('prod', undefined, () => 'smoke')).toBe( + join(homedir(), '.minimax-smoke'), + ); + }); + + it.each([ + [{}, '/default'], + [{ MINIMAX_DATA_DIR: ' ', MAVIS_DATA_DIR: ' ' }, '/default'], + [{ MINIMAX_DATA_DIR: ' /public ', MAVIS_DATA_DIR: '/legacy' }, '/public'], + [{ MINIMAX_DATA_DIR: ' ', MAVIS_DATA_DIR: ' /legacy ' }, '/legacy'], + ])('preserves override precedence for %j', (environment, expected) => { + expect(getTuiDataDirPath(environment, () => '/default')).toBe(expected); + }); + + it('passes the selected directory to runtime initialization', async () => { + const configureRuntimeEnvironment = vi.fn(); + await expect(prepareTuiDataDir({ + environment: { MINIMAX_DATA_DIR: ' /selected ' }, + getBuildEnv: () => 'prod', + configureRuntimeEnvironment, + })).resolves.toBe('/selected'); + expect(configureRuntimeEnvironment).toHaveBeenCalledWith({ dataDir: '/selected' }); + }); +}); diff --git a/packages/tui/test/unit/tui-model-picker.test.ts b/packages/tui/test/unit/tui-model-picker.test.ts index e06b43f69..0042751aa 100644 --- a/packages/tui/test/unit/tui-model-picker.test.ts +++ b/packages/tui/test/unit/tui-model-picker.test.ts @@ -3,6 +3,28 @@ import { describe, expect, it, vi } from "vitest"; import { TuiModelPicker } from "../../src/tui/features/model/picker.js"; describe("TuiModelPicker", () => { + it("selects each declared Kimi K3 effort without inventing a medium level", () => { + const onSelect = vi.fn(); + const model = { + providerId: "custom_provider:moonshotai", + modelId: "kimi-k3", + selected: true, + effortOptions: ["low", "high", "max"], + }; + const picker = new TuiModelPicker([model], onSelect, vi.fn()); + expect(stripAnsi(picker.render(80).join("\n"))).toContain("[ high ]"); + for (const [key, effort] of [ + ["\u001b[D", "low"], + ["\u001b[C", "high"], + ["\u001b[C", "max"], + ] as const) { + picker.handleInput(key); + expect(stripAnsi(picker.render(80).join("\n"))).toContain(`[ ${effort} ]`); + picker.handleInput("\r"); + expect(onSelect).toHaveBeenLastCalledWith(model, effort); + } + }); + it("switches M3 context independently of thinking and restores the saved choice", () => { const model = { providerId: "minimax", diff --git a/packages/tui/test/unit/tui-thinking-preview.test.ts b/packages/tui/test/unit/tui-thinking-preview.test.ts index 7ab77738a..41235c4f4 100644 --- a/packages/tui/test/unit/tui-thinking-preview.test.ts +++ b/packages/tui/test/unit/tui-thinking-preview.test.ts @@ -16,6 +16,31 @@ function thinkingStore(content: string, status: 'running' | 'failed' | 'succeede } describe('Thinking preview', () => { + it.each(['assistant', 'thinking'] as const)( + 'sanitizes %s in the expanded transcript inspector', + (kind) => { + const control = '\x1b]52;c;U1lOVEhFVElD\x07'; + const store = new TranscriptStore([ + createTranscriptCell({ + id: 'untrusted-inspector', + kind, + status: 'succeeded', + content: `${control}\x1b[8mVisible text\x1b[0m`, + createdAtMs: 1, + }), + ]); + const panel = new TuiTranscriptPanel({ + source: store, + onCancel: () => undefined, + requestRender: () => undefined, + }); + panel.handleInput('\u000f'); + const rendered = panel.render(100).join('\n'); + expect(rendered).not.toContain(control); + expect(rendered).not.toContain('\x1b[8m'); + expect(stripVTControlCharacters(rendered)).toContain('Visible text'); + }, + ); afterEach(() => vi.restoreAllMocks()); it('bounds colored layout work while new deltas stream into the same full cell', () => { diff --git a/packages/tui/test/unit/tui-transcript-view.test.ts b/packages/tui/test/unit/tui-transcript-view.test.ts index 5d96c2aec..e6e0f6044 100644 --- a/packages/tui/test/unit/tui-transcript-view.test.ts +++ b/packages/tui/test/unit/tui-transcript-view.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; import { TranscriptView, resolveTuiDecisionColor } from '../../src/tui/transcript/view.js'; import { createTranscriptCell } from '../../src/tui/transcript/model.js'; import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { sanitizeTerminalText } from '../../src/tui/rendering/terminal-text.js'; import { applyTuiRenderTheme, createTuiChalk, @@ -20,6 +21,57 @@ const previewDisplayModes = { }; describe('TranscriptView', () => { + it.each(['assistant', 'assistant-preamble', 'thinking'] as const)( + 'removes control strings from %s content without changing stored model output', + (kind) => { + const control = '\x1b]52;c;U1lOVEhFVElD\x07'; + const content = `**Visible**\n\n\`\`\`text\n${control}\x1b[8mSafe code\x1b[0m\n\`\`\``; + const cell = createTranscriptCell({ + id: 'untrusted-markdown', + kind, + status: 'running', + content, + createdAtMs: 1, + }); + const view = new TranscriptView(() => [cell], { + displayModes: { revision: 0, resolveMainDisplayMode: () => 'expanded' }, + }); + const rendered = view.render(100).join('\n'); + expect(rendered).not.toContain(control); + expect(rendered).not.toContain('\x1b[8m'); + expect(stripVTControlCharacters(rendered)).toContain('Safe code'); + expect(cell.content).toBe(content); + }, + ); + + it('safely renders every streaming prefix including C1 and unterminated control strings', () => { + const content = 'Visible\x9d52;c;U1lOVEhFVElD\x9c\x1bPprivate payload\x1b\\\x1b[8mtext\x1b[0m'; + for (let end = 1; end <= content.length; end++) { + const text = sanitizeTerminalText(content.slice(0, end)); + expect(text).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/u); + expect(text).not.toContain('private payload'); + expect(text).not.toContain('U1lOVEhFVElD'); + } + expect(sanitizeTerminalText('中文🙂\ttext\r\nnext\rother')).toBe('中文🙂\ttext\nnext other'); + }); + it.each(['running', 'succeeded'] as const)( + 'does not emit model terminal controls from a %s assistant cell', + (status) => { + const controls = ['\x1b]52;c;U1lOVEhFVElD\x07', '\x1b]2;FORGED_TITLE\x07', '\x1b[8m']; + const view = new TranscriptView(() => [ + createTranscriptCell({ + id: 'untrusted-controls', + kind: 'assistant', + status, + content: `Visible ${controls.join('')}model text\x1b[0m`, + createdAtMs: 1, + }), + ]); + const rendered = view.render(100).join('\n'); + for (const control of controls) expect(rendered).not.toContain(control); + expect(stripVTControlCharacters(rendered)).toContain('Visible model text'); + }, + ); it('renders a Markdown question receipt (Plan Review) as formatted prose', () => { const view = new TranscriptView(() => [ createTranscriptCell({ diff --git a/packages/tui/test/unit/update-application.test.ts b/packages/tui/test/unit/update-application.test.ts index 3b6f39a69..bc4a68656 100644 --- a/packages/tui/test/unit/update-application.test.ts +++ b/packages/tui/test/unit/update-application.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; @@ -7,6 +7,7 @@ import { type McodeUpdateApplicationDependencies, } from '../../src/update/application.js'; import { + bindMcodeNpmCommandToRuntime, buildMcodePackageManagerCommand, classifyMcodeInstallPath, classifyNpmGlobalInstall, @@ -25,6 +26,149 @@ import { writeMcodePrefixUpdatePending, } from '../../src/update/prefix-update.js'; +describe('installer-owned npm runtime binding', () => { + const target = 'nodejs/26.3.1/lib/node_modules/npm/bin/npm-cli.js'; + const shim = (cli = target) => + [ + '#!/bin/sh', + 'basedir=$(dirname "$(echo "$0" | sed -e \'s,\\\\,/,g\')")', + 'if [ -x "$basedir/node" ]; then', + ` exec "$basedir/node" "$basedir/${cli}" "$@"`, + 'else', + ` exec node "$basedir/${cli}" "$@"`, + 'fi', + ].join('\n'); + + async function withLayout( + run: (root: string, write: (file: string, text?: string) => string) => void | Promise, + ) { + const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'mcode-npm-shim-'))); + const write = (file: string, text = '') => { + const fullPath = path.join(root, file); + mkdirSync(path.dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, text); + return fullPath; + }; + try { + await run(root, write); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + + function bind(executable: string) { + return bindMcodeNpmCommandToRuntime( + { executable, args: ['view', '@minimax-ai/code@latest', '--json'], display: 'owned npm' }, + process.execPath, + ); + } + + it('resolves the reported pnpm shell shim and runs its CLI with the selected Node', async () => { + await withLayout(async (root, write) => { + const marker = path.join(root, 'shim-was-executed'); + const npm = write( + 'pnpm home/npm', + shim().replace('#!/bin/sh', `#!/bin/sh\ntouch "${marker}"`), + ); + const cli = write(`pnpm home/${target}`, 'console.log(JSON.stringify("1.2.4"));'); + // A different installed pnpm Node version must not influence the selection. + write( + 'pnpm home/nodejs/24.2.0/lib/node_modules/npm/bin/npm-cli.js', + 'throw new Error("wrong npm");', + ); + expect(bind(npm)).toEqual({ + executable: process.execPath, + args: [cli, 'view', '@minimax-ai/code@latest', '--json'], + display: 'owned npm', + }); + await expect( + resolveLatestMcodeRegistryVersion('latest', { + npmExecutable: npm, + runtimeExecutable: process.execPath, + distribution: resolveMcodeNpmDistribution('@minimax-ai/code'), + }), + ).resolves.toBe('1.2.4'); + expect(existsSync(marker)).toBe(false); + }); + }); + + it.skipIf(process.platform === 'win32')( + 'preserves regular npm symlinks and resolves symlinked pnpm shims', + async () => { + await withLayout((root, write) => { + const cli = write(`pnpm home/${target}`); + const npm = write('pnpm home/npm', shim()); + symlinkSync(npm, path.join(root, 'npm')); + expect(bind(path.join(root, 'npm')).args[0]).toBe(cli); + const regularCli = write('node/lib/node_modules/npm/bin/npm-cli.js'); + mkdirSync(path.join(root, 'node/bin'), { recursive: true }); + symlinkSync('../lib/node_modules/npm/bin/npm-cli.js', path.join(root, 'node/bin/npm')); + expect(bind(path.join(root, 'node/bin/npm')).args[0]).toBe(regularCli); + }); + }, + ); + + it.each([ + ['node/npm.cmd', 'node/node_modules/npm/bin/npm-cli.js'], + ['node/bin/npm', 'node/lib/node_modules/npm/bin/npm-cli.js'], + ])('preserves conventional npm layout for %s', async (executable, cliPath) => { + await withLayout((_root, write) => { + const npm = write(executable, '@ECHO OFF\r\n'); + const cli = write(cliPath); + expect(bind(npm).args[0]).toBe(cli); + }); + }); + + it.each([ + ['missing target', shim()], + ['directory target', shim()], + ['traversal', shim(`../${target}`)], + ['dynamic version', shim('nodejs/$(node -v)/lib/node_modules/npm/bin/npm-cli.js')], + [ + 'ambiguous branches', + shim().replace('exec node "$basedir/nodejs/26.3.1/', 'exec node "$basedir/nodejs/24.2.0/'), + ], + ['comment-only target', `#!/bin/sh\n# exec node "$basedir/${target}" "$@"`], + ['unknown wrapper', '#!/bin/sh\nexec npm "$@"'], + ])('rejects %s without searching other installed versions', async (kind, content) => { + await withLayout((root, write) => { + const npm = write('pnpm/npm', content); + write('pnpm/nodejs/24.2.0/lib/node_modules/npm/bin/npm-cli.js'); + if (kind === 'directory target') + mkdirSync(path.join(root, 'pnpm', target), { recursive: true }); + else if (kind !== 'missing target') write(`pnpm/${target}`); + write(target); + expect(() => bind(npm)).toThrow('Cannot locate npm-cli.js for the owned npm executable'); + }); + }); + + it.skipIf(process.platform === 'win32').each(['file', 'version directory', 'dangling file'])( + 'rejects a pnpm %s symlink escaping its version directory', + async (kind) => { + await withLayout((root, write) => { + const npm = write('pnpm/npm', shim()); + const outside = write('outside/lib/node_modules/npm/bin/npm-cli.js'); + const cli = path.join(root, 'pnpm', target); + if (kind === 'version directory') { + mkdirSync(path.join(root, 'pnpm/nodejs'), { recursive: true }); + symlinkSync(path.join(root, 'outside'), path.join(root, 'pnpm/nodejs/26.3.1')); + } else { + mkdirSync(path.dirname(cli), { recursive: true }); + symlinkSync(kind === 'dangling file' ? `${outside}.missing` : outside, cli); + } + expect(() => bind(npm)).toThrow('Cannot locate npm-cli.js for the owned npm executable'); + }); + }, + ); + + it('rejects a missing owned executable even when a conventional CLI exists', async () => { + await withLayout((root, write) => { + write('node_modules/npm/bin/npm-cli.js'); + expect(() => bind(path.join(root, 'npm'))).toThrow(); + }); + }); +}); + describe('McodeUpdateApplication', () => { it('keeps signed managed-installer check and apply behind one product intent', async () => { const check = vi.fn(async () => ({ diff --git a/release/public-source.json b/release/public-source.json index e3ee9e72e..4279497b0 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1,6 +1,11 @@ { "schemaVersion": 1, "files": [ + ".agents/skills/cli-guide/SKILL.md", + ".agents/skills/cross-layer-drift-sweep/SKILL.md", + ".agents/skills/retro/SKILL.md", + ".agents/skills/testing-workflow/SKILL.md", + ".agents/skills/verify-all-runtime-sinks/SKILL.md", ".gitattributes", ".github/CODEOWNERS", ".github/ISSUE_TEMPLATE/01-bug-report.yml", @@ -100,6 +105,7 @@ "packages/agent-core/src/tools/index.ts", "packages/agent-core/src/tools/input-validation.ts", "packages/agent-core/src/tools/types.ts", + "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-core/test/unit/event-bridge/converters.test.ts", "packages/agent-core/test/unit/image-dimensions.test.ts", "packages/agent-core/test/unit/image-fixtures.ts", @@ -140,6 +146,7 @@ "packages/agent-modules/context-manager/src/settings.ts", "packages/agent-modules/context-manager/src/token-estimator.ts", "packages/agent-modules/context-manager/src/types.ts", + "packages/agent-modules/context-manager/test/compaction-usage.test.ts", "packages/agent-modules/conversation-contract/package.json", "packages/agent-modules/conversation-contract/src/index.ts", "packages/agent-modules/conversation-contract/src/process-local.ts", @@ -234,6 +241,7 @@ "packages/agent-modules/permission/src/types.ts", "packages/agent-modules/permission/src/windows-trash-execution.ts", "packages/agent-modules/permission/src/written-files-registry.ts", + "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", "packages/agent-modules/permission/test/unit/permission/cloud-classify-client.test.ts", "packages/agent-modules/plugin-hooks/package.json", "packages/agent-modules/plugin-hooks/src/command-invocation.ts", @@ -382,6 +390,7 @@ "packages/agent-tools/src/shared/read-postprocess.ts", "packages/agent-tools/src/shared/read-result.ts", "packages/agent-tools/src/shared/read-video.ts", + "packages/agent-tools/src/shared/replace-all-edit.test.ts", "packages/agent-tools/src/shared/replace-all-edit.ts", "packages/agent-tools/src/shared/rg-scan-policy.ts", "packages/agent-tools/src/shared/rg-sensitive.ts", @@ -440,6 +449,7 @@ "packages/config/src/opencode-config.ts", "packages/config/src/permission-config.ts", "packages/config/src/pid-file.ts", + "packages/config/src/private-config-file.ts", "packages/config/src/provider-auth-mode.ts", "packages/config/src/review-config.ts", "packages/config/src/runaway-guard-config.ts", @@ -452,6 +462,7 @@ "packages/config/src/tool-result-compaction-config.ts", "packages/config/src/tui-config.ts", "packages/config/src/tui-status-line-write.ts", + "packages/config/test/config-file-permissions.test.ts", "packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md", "packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs", "packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md", @@ -2624,6 +2635,7 @@ "packages/local-runtime/test/unit/local-thread-goal-orchestrator.test.ts", "packages/local-runtime/test/unit/local-thread-goal-store.test.ts", "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", + "packages/local-runtime/test/unit/session-diff-fallback.test.ts", "packages/local-runtime/test/unit/thread-goal-kickoff-host.test.ts", "packages/local-runtime/test/unit/thread-goal-verifier-contract.test.ts", "packages/local-runtime/test/unit/thread-goal/contract.test.ts", @@ -3199,10 +3211,12 @@ "packages/tui/test/unit/headless-runner.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/model-effort.test.ts", + "packages/tui/test/unit/network-proxy.test.ts", "packages/tui/test/unit/plugin-application.test.ts", "packages/tui/test/unit/provider-application.test.ts", "packages/tui/test/unit/run-coordinator-usage.test.ts", "packages/tui/test/unit/run-coordinator.test.ts", + "packages/tui/test/unit/runtime-data-dir.test.ts", "packages/tui/test/unit/runtime-event-normalizer.test.ts", "packages/tui/test/unit/runtime-feedback-diagnostic-upload.test.ts", "packages/tui/test/unit/runtime-feedback-service.test.ts", @@ -3277,6 +3291,7 @@ "test/byok.test.mjs", "test/executable-resolution.test.ts", "test/network-deny.mjs", + "test/offline-environment.mjs", "test/public-artifact.test.mjs", "test/smoke.test.mjs", "test/source-sync.test.mjs", diff --git a/scripts/run-vitest-suite.mjs b/scripts/run-vitest-suite.mjs index 19f23f500..ffc0c8909 100644 --- a/scripts/run-vitest-suite.mjs +++ b/scripts/run-vitest-suite.mjs @@ -26,6 +26,10 @@ const result = spawnSync( "run", "--config", "vitest.oss.config.mjs", + // These suites create many SQLite databases and watched directories. On + // Windows, concurrent files can exhaust the 5s test budget through I/O + // contention. Serialize files without relaxing individual test deadlines. + ...(process.platform === "win32" ? ["--maxWorkers", "1"] : []), ...files, ], { stdio: "inherit", cwd: repositoryRoot, env: environment }, diff --git a/test/byok.test.mjs b/test/byok.test.mjs index ab131ab5b..78facd3b4 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -14,6 +14,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { withoutProxyEnvironment } from "./offline-environment.mjs"; const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); // This fixture validates BYOK transport and real Runtime persistence, not model quality. @@ -21,7 +23,10 @@ test( "BYOK runs without managed login and resumes its saved conversation", { timeout: 90000 }, async (t) => { - const dataDir = mkdtempSync(path.join(tmpdir(), "minimax-code-byok-")); + const fixtureDir = mkdtempSync(path.join(tmpdir(), "minimax-code-byok-")); + const dataDir = path.join(fixtureDir, "data"); + const workspaceDir = path.join(fixtureDir, "workspace"); + mkdirSync(workspaceDir); const dbPath = path.join(dataDir, "v2", "sqlite", "runtime-state.sqlite"); mkdirSync(path.dirname(dbPath), { recursive: true }); const legacyDb = new Database(dbPath); @@ -36,8 +41,9 @@ test( } const requests = []; const readMarker = `ACTUAL_FILE_CONTENT_${Date.now()}`; - writeFileSync(path.join(dataDir, "read-fixture.txt"), readMarker); + writeFileSync(path.join(workspaceDir, "read-fixture.txt"), readMarker); let toolRequested = false; + let rejectConnection = false; const networkAudit = path.join(dataDir, "network-audit.log"); const server = createServer(async (req, res) => { let raw = ""; @@ -48,6 +54,12 @@ test( res.writeHead(404).end(); return; } + if (rejectConnection) { + res.writeHead(401, { "content-type": "application/json" }).end( + JSON.stringify({ error: { message: "Synthetic invalid credential" } }), + ); + return; + } if (!body.stream) { res.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ @@ -88,7 +100,7 @@ test( function: { name: "read", arguments: JSON.stringify({ - path: path.join(dataDir, "read-fixture.txt"), + path: path.join(workspaceDir, "read-fixture.txt"), }), }, }, @@ -139,12 +151,11 @@ test( : "Unexpected external request", ); } finally { - rmSync(dataDir, { recursive: true, force: true }); + rmSync(fixtureDir, { recursive: true, force: true }); } }); const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; const env = { - ...process.env, MINIMAX_DATA_DIR: dataDir, MAVIS_DATA_DIR: dataDir, MCODE_PROVIDER_API_KEY: "fixture-only-key", @@ -153,12 +164,9 @@ test( MCODE_TEST_MANAGED_OFFLINE: "1", MCODE_TEST_PROCESS_PROBE: "1", NODE_OPTIONS: `--import=${new URL("./network-deny.mjs", import.meta.url).href}`, - HTTP_PROXY: "", - HTTPS_PROXY: "", - ALL_PROXY: "", }; let commandSequence = 0; - async function run(args) { + async function run(args, environment = process.env) { const startedAt = Date.now(); const label = args.slice(0, 2).join(" "); const requestCountAtStart = requests.length; @@ -168,8 +176,8 @@ test( : args; return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cli, ...commandArgs], { - cwd: dataDir, - env, + cwd: workspaceDir, + env: { ...withoutProxyEnvironment(environment), ...env }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = "", @@ -229,6 +237,7 @@ test( "--model", "fixture-model", ]); + assert.equal(requests.length, 0, "Adding without --use must not test or activate"); const snapshot = JSON.parse(await run(["provider", "list", "--json"])); assert.equal( snapshot.providers.some((p) => p.kind === "minimax-oauth"), @@ -238,18 +247,125 @@ test( (p) => p.kind === "custom" && p.name === "Fixture", ); assert.ok(selected?.hasApiKey); - await run([ - "provider", - "test", - selected.providerId, - "--model", - "fixture-model", - ]); + const proxyNames = [ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + ]; + const cleanEnvironment = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !proxyNames.includes(name)), + ); + const proxyValue = (name) => name.toLowerCase() === "no_proxy" + ? "*" + : "http://127.0.0.1:9"; + for (const [label, proxies] of [ + ["no proxy", {}], + ...proxyNames.map((name) => [name, { [name]: proxyValue(name) }]), + ["uppercase and lowercase together", Object.fromEntries( + proxyNames.map((name) => [name, proxyValue(name)]), + )], + ]) { + await t.test(`offline BYOK ignores ambient proxies: ${label}`, async () => { + const environment = Object.freeze({ ...cleanEnvironment, ...proxies }); + // NO_PROXY alone does not enable proxy mode, so check its isolation explicitly. + const isolated = withoutProxyEnvironment(environment); + for (const name of proxyNames) assert.equal(isolated[name], ""); + assert.equal(isolated.PATH, environment.PATH); + const beforeRequests = requests.length; + const managedAudit = `${networkAudit}.managed`; + const beforeManaged = readFileSync(managedAudit, "utf8").length; + await run([ + "provider", "test", selected.providerId, "--model", "fixture-model", + ], environment); + assert.ok(requests.length > beforeRequests, "The local provider must receive the request"); + assert.equal(requests[beforeRequests].body.model, "fixture-model"); + assert.match( + readFileSync(managedAudit, "utf8").slice(beforeManaged), + /https:\/\/models\.dev\/api\.json|\/mavis\/api\/v1\/models-dev\/catalog/, + ); + assert.equal(existsSync(networkAudit), false, "No outbound network attempt is allowed"); + for (const [name, value] of Object.entries(proxies)) assert.equal(environment[name], value); + }); + } + const configPath = path.join(dataDir, "config.yaml"); + const savedConfig = () => parseYaml(readFileSync(configPath, "utf8")); + assert.equal(savedConfig().defaultModel, "minimax/MiniMax-M3"); + assert.equal(savedConfig().custom_provider.fixture.models["fixture-model"].limit, undefined); + assert.equal(selected.active, false); + assert.equal(selected.models[0].contextLimit, undefined); + assert.equal(selected.models[0].maxOutputTokens, undefined); + + const addArgs = [ + "provider", "add", "--name", "Limited", "--base-url", baseUrl, + "--api-format", "openai-completions", "--model", "fixture-model", + "--model", "second-model", "--context-limit", "32768", "--output-limit", "4096", "--use", + ]; + const beforeFailure = readFileSync(configPath, "utf8"); + rejectConnection = true; + await assert.rejects(run(addArgs), /Provider connection test failed.*Nothing was saved/s); + assert.equal(readFileSync(configPath, "utf8"), beforeFailure); + rejectConnection = false; + const beforeAdd = requests.length; + assert.match(await run(addArgs), /Provider added and selected: Limited/); + assert.ok(requests.length > beforeAdd, "Activation must test the candidate before saving"); + assert.equal(requests[beforeAdd].body.model, "fixture-model"); + const config = savedConfig(); + assert.equal(config.defaultModel, "custom_provider:limited/fixture-model"); + for (const model of ["fixture-model", "second-model"]) { + assert.deepEqual(config.custom_provider.limited.models[model].limit, { context: 32768, output: 4096 }); + } + const configured = JSON.parse(await run(["provider", "list", "--json"])).providers.find( + (provider) => provider.name === "Limited", + ); + assert.equal(configured.active, true); + assert.equal(configured.models[0].selected, true); + for (const model of configured.models) { + assert.equal(model.contextLimit, 32768); + assert.equal(model.maxOutputTokens, 4096); + } + const beforeSaveOnly = requests.length; + for (const [name, flag, limit] of [ + ["context-only", "--context-limit", { context: 32768 }], + ["output-only", "--output-limit", { output: 32768 }], + ]) { + await run([ + "provider", "add", "--name", name, "--base-url", baseUrl, + "--api-format", "openai-completions", "--model", "fixture-model", flag, "32768", + ]); + assert.deepEqual(savedConfig().custom_provider[name].models["fixture-model"].limit, limit); + assert.equal(savedConfig().defaultModel, config.defaultModel); + } + assert.equal(requests.length, beforeSaveOnly, "Limits alone must not test or select a model"); + // Exercise the metadata persisted by provider preset import through real + // config reload, headless validation and the OpenAI-compatible request body. + const effortConfig = savedConfig(); + effortConfig.custom_provider.fixture.models["kimi-k3"] = { + reasoning: true, + thinking: { effortOptions: ["low", "high", "max"] }, + }; + writeFileSync(configPath, stringifyYaml(effortConfig)); + const effortModel = `${selected.providerId}/kimi-k3`; + for (const effort of ["low", "high", "max"]) { + const beforeEffort = requests.length; + await run(["exec", "EFFORT_TEST", "--model", effortModel, "--effort", effort, + "--timeout", "20s", "--max-steps", "1"]); + // Background title generation is a separate non-streaming request and + // does not use the turn's effort selection. + const modelRequests = requests.slice(beforeEffort).filter( + (r) => r.body.model === "kimi-k3" && r.body.stream === true, + ); + assert.ok(modelRequests.length > 0); + for (const request of modelRequests) assert.equal(request.body.reasoning_effort, effort); + } + const beforeInvalidEffort = requests.length; + await assert.rejects(run(["exec", "EFFORT_TEST", "--model", effortModel, + "--effort", "medium", "--timeout", "20s", "--max-steps", "1"]), + /Available levels: low, high, max/); + assert.equal(requests.length, beforeInvalidEffort, "Invalid effort must fail before transport"); const modelArgs = ["--model", `${selected.providerId}/fixture-model`]; + // The first run must work through the saved default, without --model or managed login. const first = await run([ "exec", "Remember this marker: SOURCE_REPOSITORY_TEST", - ...modelArgs, "--timeout", "20s", "--max-steps", diff --git a/test/offline-environment.mjs b/test/offline-environment.mjs new file mode 100644 index 000000000..69acacf5d --- /dev/null +++ b/test/offline-environment.mjs @@ -0,0 +1,15 @@ +// Proxy setup installs undici.fetch over the preloaded offline mock. +// Keep host proxy settings out of isolated CLI children without changing the parent. +export function withoutProxyEnvironment(environment = process.env) { + return { + ...environment, + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + NO_PROXY: "", + http_proxy: "", + https_proxy: "", + all_proxy: "", + no_proxy: "", + }; +} diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 50ecaa890..c877de87b 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -1,18 +1,27 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, chmodSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; +import { withoutProxyEnvironment } from "./offline-environment.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const cli = path.join(root, "dist/cli.js"); +// Full runtime startup can exceed 15s on Windows CI (ACP took 22s). +// Match the ACP startup budget; lightweight help/validation stays at 15s. +const runtimeTimeoutMs = process.platform === "win32" ? 30000 : 15000; +function assertSuccessfulChild(result) { + assert.equal(result.error, undefined, + `CLI spawn failed: ${result.error?.message}; signal=${result.signal}; stderr=${result.stderr}`); + assert.equal(result.status, 0, result.stderr); +} const version = JSON.parse( readFileSync(path.join(root, "packages/tui/package.json"), "utf8"), ).version; -function fixture(t) { +function fixture(t, environment = process.env) { const dataDir = mkdtempSync(path.join(tmpdir(), "minimax-code-smoke-")); const audit = path.join(dataDir, "network-attempts.log"); t.after(() => { @@ -31,11 +40,12 @@ function fixture(t) { return { cwd: dataDir, env: { - ...process.env, + ...withoutProxyEnvironment(environment), MINIMAX_DATA_DIR: dataDir, MAVIS_DATA_DIR: dataDir, MCODE_TEST_NETWORK_AUDIT: audit, MCODE_TEST_MANAGED_OFFLINE: "1", + MCODE_TEST_PROCESS_PROBE: "1", NODE_OPTIONS: `--import=${new URL("./network-deny.mjs", import.meta.url).href}`, }, }; @@ -56,17 +66,134 @@ test("CLI version and command help work outside the source directory", (t) => { assert.ok(result.stdout.includes(expected), result.stdout); } }); +test("CLI defaults to the shared user config without migrating the old source directory", (t) => { + const options = fixture(t); + const home = options.cwd; + const config = path.join(home, ".minimax", "config.yaml"); + const oldConfig = path.join(home, ".minimax-code", "config.yaml"); + for (const file of [config, oldConfig]) mkdirSync(path.dirname(file)); + writeFileSync(config, "telemetry:\n enabled: true\n", { mode: 0o600 }); + const oldContents = "telemetry:\n enabled: false\n"; + writeFileSync(oldConfig, oldContents, { mode: 0o600 }); + for (const name of Object.keys(options.env)) { + if (name.startsWith("__MAVIS_RUNTIME")) delete options.env[name]; + } + delete options.env.MINIMAX_DATA_DIR; + delete options.env.MAVIS_DATA_DIR; + Object.assign(options.env, { + HOME: home, + USERPROFILE: home, + MCODE_DISABLE_TELEMETRY: "1", + }); + const result = spawnSync(process.execPath, [cli, "telemetry", "status"], { + ...options, + encoding: "utf8", + timeout: runtimeTimeoutMs, + }); + assertSuccessfulChild(result); + const status = JSON.parse(result.stdout); + assert.equal(status.configFile, config); + assert.equal(status.configured, true); + assert.equal(readFileSync(oldConfig, "utf8"), oldContents); +}); test("provider configuration loads from an isolated data directory", (t) => { const result = spawnSync(process.execPath, [cli, "provider", "list"], { ...fixture(t), encoding: "utf8", - timeout: 15000, + timeout: runtimeTimeoutMs, }); - assert.equal(result.status, 0, result.stderr); + assertSuccessfulChild(result); assert.match(result.stdout, /minimax/); assert.doesNotMatch(result.stdout, /custom_provider:/); }); +test("config permission failures preserve private reads and terminate unsafe startup", { + skip: process.platform !== "darwin", +}, (t) => { + const options = fixture(t); + const config = path.join(options.env.MINIMAX_DATA_DIR, "config.yaml"); + const backup = `${config}.bak.byok-legacy-provider.smoke`; + const flag = (file, value) => { + const result = spawnSync("chflags", [value, file], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + }; + const list = () => { + const result = spawnSync(process.execPath, [cli, "provider", "list"], { + ...options, + encoding: "utf8", + timeout: 15000, + // A leaked startup watcher can handle SIGTERM without releasing the process. + killSignal: "SIGKILL", + }); + assert.equal(result.error, undefined, result.stderr); + return result; + }; + try { + for (const file of [config, backup]) { + writeFileSync(file, "logLevel: info\n", { mode: 0o400 }); + flag(file, "uchg"); + } + const privateRead = list(); + assert.equal(privateRead.status, 0, privateRead.stderr); + assert.doesNotMatch(privateRead.stderr, /Could not restrict legacy BYOK backup/); + assert.equal(statSync(config).mode & 0o777, 0o400); + assert.equal(statSync(backup).mode & 0o777, 0o400); + + flag(backup, "nouchg"); + chmodSync(backup, 0o644); + flag(backup, "uchg"); + const backupWarning = list(); + assert.equal(backupWarning.status, 0, backupWarning.stderr); + assert.match(backupWarning.stderr, /Could not restrict legacy BYOK backup/); + assert.ok(backupWarning.stderr.includes(JSON.stringify(backup))); + assert.equal(statSync(backup).mode & 0o777, 0o644); + + flag(config, "nouchg"); + chmodSync(config, 0o644); + flag(config, "uchg"); + const unsafeRead = list(); + assert.equal(unsafeRead.status, 1, unsafeRead.stderr); + assert.match(unsafeRead.stderr, /EPERM/); + assert.equal(statSync(config).mode & 0o777, 0o644); + } finally { + for (const file of [config, backup]) { + if (existsSync(file)) flag(file, "nouchg"); + } + } +}); + +test("offline smoke children ignore ambient proxy variables", async (t) => { + const proxyNames = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ]; + for (const name of proxyNames) { + await t.test(name, (t) => { + const environment = { + ...process.env, + ...Object.fromEntries(proxyNames.map((key) => [key, ""])), + [name]: "http://127.0.0.1:9", + }; + const options = fixture(t, environment); + const result = spawnSync(process.execPath, [cli, "provider", "list"], { + ...options, + encoding: "utf8", + timeout: runtimeTimeoutMs, + }); + assertSuccessfulChild(result); + assert.match(result.stdout, /minimax/); + assert.match( + readFileSync(options.env.MCODE_TEST_NETWORK_AUDIT + ".managed", "utf8"), + /https:\/\/models\.dev\/api\.json|\/mavis\/api\/v1\/models-dev\/catalog/, + ); + assert.equal(environment[name], "http://127.0.0.1:9"); + }); + } +}); test("native SQLite binding opens and reads a real database", () => { const db = new Database(":memory:"); try { @@ -183,9 +310,9 @@ test("local plugin browsing remains available with managed services offline", (t const result = spawnSync(process.execPath, [cli, ...args], { ...options, encoding: "utf8", - timeout: 15000, + timeout: runtimeTimeoutMs, }); - assert.equal(result.status, 0, result.stderr); + assertSuccessfulChild(result); const output = JSON.parse(result.stdout); assert.ok(output !== null); assert.doesNotMatch(result.stdout, /"official"/); @@ -199,3 +326,22 @@ test("local plugin browsing remains available with managed services offline", (t assert.match(result.stdout, /Usage:/); } }); + +test("provider add validates token limits before opening the runtime", (t) => { + const options = fixture(t); + const args = [ + "provider", "add", "--name", "Invalid limits", "--base-url", "http://127.0.0.1:1/v1", + "--model", "synthetic-model", + ]; + for (const flag of ["--context-limit", "--output-limit"]) { + for (const value of ["0", "-1", "1.5", "NaN", "Infinity", "9007199254740992"]) { + const result = spawnSync(process.execPath, [cli, ...args, `${flag}=${value}`], { + ...options, encoding: "utf8", timeout: 15000, + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /positive safe integer/); + assert.ok(result.stderr.includes(flag), result.stderr); + } + } + assert.equal(existsSync(path.join(options.cwd, "config.yaml")), false); +}); diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index b0caead77..c1085e1aa 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -404,16 +404,21 @@ test('suite runner preserves gate arguments and canonicalizes Windows temporary writeFileSync(path.join(root, 'node_modules/vitest/cli.cjs'), 'console.log(JSON.stringify({args:process.argv.slice(2),temp:process.env.TEMP,tmp:process.env.TMP,cwd:process.cwd()})); process.exit(17);'); const alias = path.join(root, 'temporary-alias'); symlinkSync(path.join(root, 'temporary'), alias, process.platform === 'win32' ? 'junction' : 'dir'); - const result = spawnSync(process.execPath, [path.join(root, 'scripts/run-vitest-suite.mjs'), 'fixture'], { - encoding: 'utf8', env: { ...process.env, TEMP: alias, TMP: alias }, - }); - assert.equal(result.status, 17, result.stderr); - const child = JSON.parse(result.stdout); - assert.deepEqual(child.args, ['run', '--config', 'vitest.oss.config.mjs', 'test/example.test.ts']); - assert.equal(realpathSync(child.cwd), realpathSync(root)); - const expected = process.platform === 'win32' ? realpathSync.native(alias) : alias; - assert.equal(child.temp, expected); - assert.equal(child.tmp, expected); + for (const platform of ['linux', 'darwin', 'win32']) { + const preload = path.join(root, 'platform.cjs'); + writeFileSync(preload, `Object.defineProperty(process, 'platform', { value: ${JSON.stringify(platform)} });`); + const result = spawnSync(process.execPath, ['--require', preload, path.join(root, 'scripts/run-vitest-suite.mjs'), 'fixture'], { + encoding: 'utf8', env: { ...process.env, TEMP: alias, TMP: alias, TMPDIR: alias }, + }); + assert.equal(result.status, 17, result.stderr); + const child = JSON.parse(result.stdout); + assert.deepEqual(child.args, ['run', '--config', 'vitest.oss.config.mjs', + ...(platform === 'win32' ? ['--maxWorkers', '1'] : []), 'test/example.test.ts']); + assert.equal(realpathSync(child.cwd), realpathSync(root)); + const expected = platform === 'win32' ? realpathSync.native(alias) : alias; + assert.equal(child.temp, expected); + assert.equal(child.tmp, expected); + } }); test('public support forms preserve destination URLs and separate Desktop from CLI reports', () => { diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 552b3c334..5404eeae7 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -2,10 +2,14 @@ "comment": "Vitest suites for the standalone distribution, grouped by the gate that runs them. `vitest.oss.config.mjs` includes every group; `scripts/run-vitest-suite.mjs ` runs one. Add a test file here rather than in package.json or the Vitest config.", "suites": { "capability": [ + "packages/agent-core/test/unit/bash-subprocess-env.test.ts", + "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", + "packages/agent-tools/src/shared/replace-all-edit.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/tui/test/unit/auth-application.test.ts", "packages/tui/test/unit/auth-session.test.ts", "packages/tui/test/unit/cli-auth-command.test.ts", + "packages/tui/test/unit/network-proxy.test.ts", "packages/tui/test/unit/application-login-gate.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/provider-application.test.ts", @@ -62,6 +66,7 @@ "packages/local-runtime/test/unit/local-background-task-task-output-polling-reminder.test.ts", "packages/mcode-tools-host/test/unit/resource-manifest.test.ts", "packages/tui/test/unit/headless-config.test.ts", + "packages/tui/test/unit/runtime-data-dir.test.ts", "packages/tui/test/unit/headless-contract.test.ts", "packages/tui/test/unit/headless-exit-policy.test.ts", "packages/tui/test/unit/headless-invocation.test.ts", @@ -136,6 +141,9 @@ "packages/tui/test/unit/tui/controller/product/command-flow.test.ts", "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", + "packages/config/test/config-file-permissions.test.ts", + "packages/agent-modules/context-manager/test/compaction-usage.test.ts", + "packages/local-runtime/test/unit/session-diff-fallback.test.ts", "packages/tui/test/unit/egress-guard.test.ts" ], "status-contract": [