From f0c92be93205d51354dfa6c74fc83d9a53d878e6 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 17:42:42 +0800 Subject: [PATCH 01/10] feat(code-sdk): add the @minimax-ai/code-sdk TypeScript SDK Add packages/code-sdk, a zero-dependency TypeScript SDK that drives the local MiniMax Code agent from Node.js by wrapping `mcode exec --output-format stream-json`: one CLI process per Turn, with a Thread holding only the Session id and continuing it with --session. - MCode / startThread / resumeThread / run / runStreamed, a ThreadBusy guard, prompts over stdin and system prompts through private temp files - CLI resolution: explicit bin, an installed @minimax-ai/code, the nearest node_modules/.bin/mcode above the working directory, then PATH; a cached minimum-version check and schemaVersion 1 enforcement - items merged by id, duplicate snapshots dropped, unfinished items marked incomplete; typed errors mapped from exec results and exit codes - AbortSignal cancellation (SIGINT, SIGTERM, SIGKILL), a timeoutMs backstop, a bounded drain after exit and cleanup on normal host exit - tests replay recorded, sanitized CLI output through a fake mcode; a contract test imports the CLI's JSONL projector and exit policy, and engines.node is pinned to the CLI's range - npm publication metadata pinned to registry.npmjs.org, a manual RELEASING.md, and a release:check script that builds, tests, packs, scans the tarball and prints the publish command without running it Repository integration: the package is registered in the workspace and release/extraction.json, its tests form the code-sdk Vitest suite, and verify gains typecheck:code-sdk and test:code-sdk (Linux and macOS). The check:source text patterns move to scripts/lib/public-text-policy.mjs so release:check reuses them. --- README.md | 1 + README_ZH.md | 1 + package.json | 2 + packages/code-sdk/CHANGELOG.md | 26 ++ packages/code-sdk/CONTRIBUTING.md | 39 +++ packages/code-sdk/LICENSE | 21 ++ packages/code-sdk/README.md | 255 ++++++++++++++ packages/code-sdk/RELEASING.md | 106 ++++++ packages/code-sdk/package.json | 67 ++++ packages/code-sdk/scripts/release-check.mjs | 225 ++++++++++++ packages/code-sdk/src/args.ts | 98 ++++++ packages/code-sdk/src/cli-resolver.ts | 185 ++++++++++ packages/code-sdk/src/errors.ts | 206 +++++++++++ packages/code-sdk/src/exec-process.ts | 323 ++++++++++++++++++ packages/code-sdk/src/index.ts | 16 + packages/code-sdk/src/items.ts | 74 ++++ packages/code-sdk/src/mcode.ts | 60 ++++ packages/code-sdk/src/project-bin.ts | 96 ++++++ packages/code-sdk/src/protocol.ts | 63 ++++ packages/code-sdk/src/thread.ts | 186 ++++++++++ packages/code-sdk/src/types.ts | 224 ++++++++++++ packages/code-sdk/src/version.ts | 109 ++++++ packages/code-sdk/test/args.test.ts | 152 +++++++++ packages/code-sdk/test/cli-contract.test.ts | 115 +++++++ packages/code-sdk/test/cli-resolution.test.ts | 169 +++++++++ packages/code-sdk/test/exit-codes.test.ts | 168 +++++++++ .../code-sdk/test/fixtures/cancelled.jsonl | 11 + .../test/fixtures/cli-package/mcode.mjs | 86 +++++ .../test/fixtures/cli-package/package.json | 13 + .../test/fixtures/failed-upstream.jsonl | 5 + packages/code-sdk/test/fixtures/resumed.jsonl | 9 + .../test/fixtures/success-tools.jsonl | 31 ++ packages/code-sdk/test/helpers.ts | 69 ++++ packages/code-sdk/test/project-bin.test.ts | 117 +++++++ packages/code-sdk/test/run.test.ts | 211 ++++++++++++ packages/code-sdk/test/stream.test.ts | 213 ++++++++++++ packages/code-sdk/tsconfig.check.json | 9 + packages/code-sdk/tsconfig.json | 10 + pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + release/extraction.json | 1 + release/public-source.json | 36 ++ scripts/lib/public-text-policy.mjs | 6 + scripts/source-inventory.mjs | 11 +- scripts/verify.mjs | 5 + test/vitest-suites.json | 9 + tsconfig.standalone.json | 6 + 47 files changed, 3847 insertions(+), 5 deletions(-) create mode 100644 packages/code-sdk/CHANGELOG.md create mode 100644 packages/code-sdk/CONTRIBUTING.md create mode 100644 packages/code-sdk/LICENSE create mode 100644 packages/code-sdk/README.md create mode 100644 packages/code-sdk/RELEASING.md create mode 100644 packages/code-sdk/package.json create mode 100644 packages/code-sdk/scripts/release-check.mjs create mode 100644 packages/code-sdk/src/args.ts create mode 100644 packages/code-sdk/src/cli-resolver.ts create mode 100644 packages/code-sdk/src/errors.ts create mode 100644 packages/code-sdk/src/exec-process.ts create mode 100644 packages/code-sdk/src/index.ts create mode 100644 packages/code-sdk/src/items.ts create mode 100644 packages/code-sdk/src/mcode.ts create mode 100644 packages/code-sdk/src/project-bin.ts create mode 100644 packages/code-sdk/src/protocol.ts create mode 100644 packages/code-sdk/src/thread.ts create mode 100644 packages/code-sdk/src/types.ts create mode 100644 packages/code-sdk/src/version.ts create mode 100644 packages/code-sdk/test/args.test.ts create mode 100644 packages/code-sdk/test/cli-contract.test.ts create mode 100644 packages/code-sdk/test/cli-resolution.test.ts create mode 100644 packages/code-sdk/test/exit-codes.test.ts create mode 100644 packages/code-sdk/test/fixtures/cancelled.jsonl create mode 100755 packages/code-sdk/test/fixtures/cli-package/mcode.mjs create mode 100644 packages/code-sdk/test/fixtures/cli-package/package.json create mode 100644 packages/code-sdk/test/fixtures/failed-upstream.jsonl create mode 100644 packages/code-sdk/test/fixtures/resumed.jsonl create mode 100644 packages/code-sdk/test/fixtures/success-tools.jsonl create mode 100644 packages/code-sdk/test/helpers.ts create mode 100644 packages/code-sdk/test/project-bin.test.ts create mode 100644 packages/code-sdk/test/run.test.ts create mode 100644 packages/code-sdk/test/stream.test.ts create mode 100644 packages/code-sdk/tsconfig.check.json create mode 100644 packages/code-sdk/tsconfig.json create mode 100644 scripts/lib/public-text-policy.mjs diff --git a/README.md b/README.md index 11e2a0e71..2db47695a 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Use `mcode init .` to generate or update project guidance in `AGENTS.md`. Descri | Interactive TUI | `mcode [prompt]` | Explore code, continue a conversation, and review changes or permissions. | | Headless | `mcode exec [prompt]` | Shell scripts, CI, batch work, and evaluations. | | ACP | `mcode acp` | Editors and clients supporting Agent Client Protocol. | +| TypeScript SDK | [`@minimax-ai/code-sdk`](packages/code-sdk/README.md) | Drive `mcode exec` from Node.js code: start, stream and resume conversations. | ### Lightweight conversations diff --git a/README_ZH.md b/README_ZH.md index 18aea27a9..341429a45 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -145,6 +145,7 @@ mcode "Find a failing test, fix the implementation, and run the relevant tests." | 交互式 TUI | `mcode [prompt]` | 探索代码、持续对话、审阅修改与权限确认。 | | Headless | `mcode exec [prompt]` | Shell、CI、批处理与评测。 | | ACP | `mcode acp` | 支持 Agent Client Protocol 的编辑器与客户端。 | +| TypeScript SDK | [`@minimax-ai/code-sdk`](packages/code-sdk/README.md) | 在 Node.js 代码中驱动 `mcode exec`:启动、流式读取和续跑对话。 | ### 轻量对话 diff --git a/package.json b/package.json index 744a69679..72f0a842c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "check:source": "node scripts/source-inventory.mjs", "check:plugin": "node tools/plugins/validate-plugin.mjs", "test:capabilities": "node scripts/run-vitest-suite.mjs capability", + "test:code-sdk": "node scripts/run-vitest-suite.mjs code-sdk", + "typecheck:code-sdk": "pnpm --filter @minimax-ai/code-sdk run typecheck", "test:windows": "node scripts/run-vitest-suite.mjs windows", "test:artifact": "node --test test/public-artifact.test.mjs", "test:release-tools": "node --test test/source-sync.test.mjs", diff --git a/packages/code-sdk/CHANGELOG.md b/packages/code-sdk/CHANGELOG.md new file mode 100644 index 000000000..f8ed63d6b --- /dev/null +++ b/packages/code-sdk/CHANGELOG.md @@ -0,0 +1,26 @@ +# `@minimax-ai/code-sdk` Changelog + +All notable changes to `@minimax-ai/code-sdk` are documented here. The SDK is versioned +independently of the MiniMax Code CLI; see the README compatibility table for supported CLI +versions. The first `## X.Y.Z · YYYY-MM-DD` heading is the version being released. + +## 0.1.0 · 2026-10-28 + +First public release. + +### Added + +- `MCode`, `startThread`, `resumeThread`, `thread.run()` and `thread.runStreamed()` drive + `mcode exec --output-format stream-json`, one CLI process per Turn. +- CLI resolution: explicit `bin`, installed `@minimax-ai/code`, the nearest + `node_modules/.bin/mcode` above the working directory, then `mcode` on `PATH`. +- Requires MiniMax Code CLI `0.7.0` or later and Node.js `>=22.19 <23 || >=24.2 <27`. +- Typed errors for invocation, run, cancellation, protocol, missing CLI, version and busy Thread + failures, mapped from the CLI exit codes. +- Cancellation through `AbortSignal` (SIGINT, then SIGTERM, then SIGKILL) and a `timeoutMs` + backstop. +- ESM with a `require()`-compatible export condition; no runtime dependencies. + +### Not yet available + +- `outputSchema`, approvals, steering and forking. diff --git a/packages/code-sdk/CONTRIBUTING.md b/packages/code-sdk/CONTRIBUTING.md new file mode 100644 index 000000000..345c27dfd --- /dev/null +++ b/packages/code-sdk/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to `@minimax-ai/code-sdk` + +This file is for repository contributors and is not part of the npm package (`files` only includes +`dist`, `src`, `README.md` and `CHANGELOG.md`). Repository-wide rules are in the root +[CONTRIBUTING.md](../../CONTRIBUTING.md) and [AGENTS.md](../../AGENTS.md); releases follow +[RELEASING.md](RELEASING.md). + +## Commands + +```bash +pnpm install --frozen-lockfile +pnpm test:code-sdk # Vitest suite "code-sdk" from test/vitest-suites.json +pnpm typecheck:code-sdk # SDK sources and tests +pnpm --filter @minimax-ai/code-sdk run build +``` + +Both gates are part of `pnpm verify`: `typecheck:code-sdk` in the full profile, `test:code-sdk` on +Linux and macOS. Add new test files to the `code-sdk` group in `test/vitest-suites.json`. + +## Test doubles and contracts + +- The tests use `test/fixtures/cli-package/mcode.mjs` as a fake CLI. It replays recorded and + sanitized `stream-json` output from `test/fixtures/*.jsonl` (a successful run with tool calls, an + upstream failure, a resumed Session and a cancellation). Mock runs are not evidence of live model + behavior. +- The same directory is linked as the `@minimax-ai/code` devDependency. pnpm installs a workspace + project's own peer dependencies, optional ones included; the link keeps the real CLI out of the + workspace and lets the tests exercise the "installed package" resolution path. +- The SDK owns its public types (`src/types.ts`). `test/cli-contract.test.ts` imports the CLI's real + JSONL projector (`packages/tui/src/headless/events.ts`) and exit-code policy + (`packages/tui/src/headless/exit-policy.ts`) to check parsing, normalization and exit-code mapping. + `test/cli-resolution.test.ts` keeps `engines.node` identical to `packages/tui/package.json`. When + you change those CLI files or the CLI Node.js range, run the SDK suite as well. + +## Published text + +`dist/`, `src/`, `README.md` and `CHANGELOG.md` are published verbatim. Keep repository paths, +CI job names and contributor instructions out of them; put them here or in `RELEASING.md`. +`release:check` scans the packed tarball before every release. diff --git a/packages/code-sdk/LICENSE b/packages/code-sdk/LICENSE new file mode 100644 index 000000000..98f0fea83 --- /dev/null +++ b/packages/code-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax Code + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/code-sdk/README.md b/packages/code-sdk/README.md new file mode 100644 index 000000000..125d28040 --- /dev/null +++ b/packages/code-sdk/README.md @@ -0,0 +1,255 @@ +# `@minimax-ai/code-sdk` + +A TypeScript SDK for driving the local MiniMax Code (`mcode`) agent programmatically from Node.js, +modeled after the [Codex TypeScript SDK](https://learn.chatgpt.com/docs/codex-sdk). Use it to run +MiniMax Code in CI/CD (fixes, reviews, code generation), embed it in internal tools or your own +agents, or orchestrate multi-turn conversations from code. + +> [!NOTE] +> +> The SDK is in Phase 1 (`0.x`): the API may change between minor versions until `1.0`. It is +> versioned independently of the CLI; see [CHANGELOG.md](CHANGELOG.md). + +## How it works + +- Each `run()` / `runStreamed()` starts one + `mcode exec --output-format stream-json --input - --input-format json` process, writes the prompt + to its stdin as `{"prompt": "..."}`, and closes stdin. **One run = one CLI process = one Turn.** +- A `Thread` only holds the Session id inside the SDK; later Turns continue the Session with + `--session `. The CLI's embedded runtime persists Sessions in its data directory, so a + crashed host can resume a Thread from its id with `resumeThread`. +- The SDK consumes only the public `mcode exec` contract (stdout JSONL plus exit code) and never + reads runtime-internal data. +- No runtime dependencies; ESM with a `require()`-compatible export condition. + +## Installation and CLI resolution + +The SDK does not hard-depend on the CLI, which is large and ships native dependencies. +`@minimax-ai/code` is an optional peer dependency: + +```bash +npm install @minimax-ai/code-sdk +npm install @minimax-ai/code # optional; a globally installed mcode works too +``` + +The SDK looks for the CLI in this order: + +1. The `bin` constructor option (an executable, a JS entry, or a command name on `PATH`). +2. An installed `@minimax-ai/code`: resolved first from the SDK's own location (where peer + dependencies are installed), then from `process.cwd()`. The SDK reads `bin.mcode` from its + `package.json` and runs that JS entry with `process.execPath`. +3. Any project-local MiniMax Code CLI: the nearest `node_modules/.bin/mcode` (`mcode.cmd` / + `mcode.ps1` on Windows) found walking up from `process.cwd()`. The SDK prefers the JS entry behind + the symlink or npm/pnpm shim and runs it with `process.execPath`, so the CLI package name does + not matter. +4. `mcode` on `PATH` (`mcode.cmd` on Windows: the JS entry behind an npm/pnpm shim is preferred; + otherwise it is started through `cmd.exe` with strictly escaped arguments). + +Before the first run, each `MCode` instance runs `mcode --version` once and caches the result. A +CLI older than `MIN_CLI_VERSION` (currently `0.7.0`) fails with `MCodeVersionError`. Only +`major.minor.patch` is compared, so `0.7.0-preview.N` satisfies `0.7.0`. + +### Compatibility + +| `@minimax-ai/code-sdk` | MiniMax Code CLI (`mcode`) | Node.js | Event `schemaVersion` | +| ---------------------- | ------------------------------ | ----------------------------- | --------------------- | +| `0.1.x` | `>=0.7.0` (`MIN_CLI_VERSION`) | `>=22.19 <23 \|\| >=24.2 <27` | `1` | + +The SDK and the CLI are released separately. Upgrading the SDK does not require upgrading the CLI +as long as the CLI meets `MIN_CLI_VERSION`. If the CLI ever emits a new event `schemaVersion`, +upgrade to an SDK release that supports it. The Node.js range matches the CLI's. + +## Quick start + +```ts +import { MCode } from '@minimax-ai/code-sdk'; + +const mcode = new MCode({ dataDir: '/srv/mcode-data' }); +const thread = mcode.startThread({ + cwd: '/path/to/repo', + model: 'minimax/MiniMax-M3', + permission: 'smart', +}); + +const turn = await thread.run('Diagnose and fix the failing CI job', { + files: ['./ci.log'], + timeoutMs: 10 * 60_000, +}); +console.log(thread.id, turn.finalResponse, turn.usage); + +// Continue the same Thread +await thread.run('Add unit tests for the fix'); + +// Resume an earlier Session +const resumed = mcode.resumeThread('mvs_xxx', { cwd: '/path/to/repo' }); +await resumed.run('Continue where you left off'); +``` + +Streaming: + +```ts +const { events } = await thread.runStreamed('Implement the plan'); +for await (const event of events) { + switch (event.type) { + case 'item.completed': + console.log(event.item); + break; + case 'turn.failed': + console.error(event.status, event.error); + break; + default: + // Newer CLIs may add event types; the SDK passes them through unchanged. + break; + } +} +``` + +## API + +### `new MCode(options?)` + +| Option | Description | +| ------------ | ----------------------------------------------------------------------------------- | +| `bin` | Explicit `mcode` path or command name; skips package and `PATH` lookup | +| `dataDir` | Data directory, passed to the CLI as the `MINIMAX_DATA_DIR` environment variable | +| `env` | Child process environment. **Replaces** `process.env` when set (no merge); defaults to `process.env` | +| `configPath` | Runtime config file, passed as `--config` | + +`env` replaces the environment entirely: passing only custom variables drops `PATH`, `HOME` and so +on, so the CLI may fail to find Node.js or tools, or to read its credentials. To add variables, +spread `process.env`: + +```ts +const mcode = new MCode({ env: { ...process.env, FOO: 'bar' } }); +``` + +### `startThread(options?)` / `resumeThread(id, options?)` + +Thread options are passed to the CLI on every Turn of the Thread: + +| Option | CLI flag | +| -------------------- | ------------------------------------------------------------------------ | +| `cwd` | `--cwd` (defaults to the host process working directory) | +| `model` / `effort` | `--model` / `--effort` | +| `permission` | `--permission`: `smart` / `full` / `off` | +| `mode` | `--mode`: `standard` / `lightweight`, only on the Turn that creates the Session | +| `promptMode` | `--prompt-mode`: `tui` / `coding` / `work` | +| `systemPrompt` | `--system-prompt-file` (the SDK writes a temporary file) | +| `appendSystemPrompt` | `--append-system-prompt-file` (the SDK writes a temporary file) | + +- `mode: 'lightweight'` is only valid for a new Thread; passing it to `resumeThread` throws + `MCodeInvocationError` immediately. +- There is no `ask` permission: headless runs cannot ask for approval. MiniMax Code's + `smart` / `full` / `off` policies are not equivalent to Codex's OS-level sandbox modes, and the + SDK does not map between them. +- `thread.id` becomes available once the first Turn emits its first event, even if that Turn fails. + +### `thread.run(input, options?)` / `thread.runStreamed(input, options?)` + +| Option | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `files` | Attachments, `--file` (repeatable); relative paths resolve against `cwd` | +| `signal` | `AbortSignal`; see [Cancellation](#cancellation-timeouts-and-process-cleanup) | +| `timeoutMs` | Passed to the CLI as `--timeout ms`; the SDK force-stops the CLI at `timeoutMs + 30s`. At most `MAX_TIMEOUT_MS` (about 24.8 days, a Node.js timer limit); larger values throw `MCodeInvocationError` | +| `maxSteps` | `--max-steps` | + +- `run()` resolves only when the status is `succeeded`, with a `Turn` (`finalResponse`, normalized + `items`, `usage`, `model`, `sessionId`, `turnId`, `durationMs` and the full `result`). Every other + outcome rejects; see [Errors](#errors). +- `runStreamed()` yields the CLI events unchanged. `turn.failed` is an ordinary event; the iterator + throws only for process or protocol failures. Breaking out of the loop cancels the Turn. +- A Thread runs one Turn at a time. A concurrent call throws `ThreadBusyError`; Turns are not queued. +- After breaking out of the iterator, or after a protocol error, the SDK sends the CLI a cancel + signal. Until the process exits (about one second for a cooperating CLI, at most about 15 seconds: + SIGINT → 10s → SIGTERM → 5s → SIGKILL), the Thread stays busy and further `run()` / + `runStreamed()` calls throw `ThreadBusyError`. The `for await` loop you broke out of also waits + for the process to exit. +- The prompt only travels over stdin, and system prompts through temporary files (mode `0600`). + The files are removed before `run()` settles and before a `runStreamed()` iterator ends. argv + contains only SDK-generated flags, identifiers and paths. + +### Events and items + +Event types match the CLI: `exec.started`, `session.started` / `session.resumed`, `turn.started`, +`item.started` / `item.updated` / `item.completed`, `turn.completed`, `turn.failed` and +`exec.completed`. Every stdout line must be JSON with `schemaVersion === 1`; anything else is a +protocol error. + +`Turn.items` are SDK-normalized items (`agent_message`, `reasoning`, `tool_call`): + +- Items are merged by `item.id` without assuming any order (events of different items interleave); + the list is in order of first appearance. +- While streaming, an `item.updated` identical to the previous snapshot of the same item is dropped. + This applies to snapshots only, never to text deltas (`contentDelta`). +- Items without an `item.completed` when the Turn ends are marked `incomplete: true` (for example, + a tool that was running when the Turn was cancelled). +- `tool_call.toolCall` is an `@experimental` `RawToolCall`: `status` is an internal numeric enum and + `input` / `output` are tool-specific structures. Neither is covered by the compatibility promise. + +`usage.totalTokens = inputTokens + outputTokens` and **excludes** `cacheReadTokens` / +`cacheWriteTokens`; `reasoningTokens` may overlap `outputTokens`. + +## Errors + +Every error extends `MCodeError` and carries `exitCode`, `signal` and the last 64 KiB of the CLI +stderr (`stderr`, for diagnostics only). + +| Source | Error | +| ---------------------------------------------------------- | ------------------------------------------------------------------ | +| Exit 2, or an option the SDK rejects | `MCodeInvocationError` | +| Status `failed` (exit 3 / 4 / 70) | `MCodeRunError` with `category` `config` / `runtime` / `internal` | +| Status `timeout` (exit 6) or the SDK timeout backstop | `MCodeRunError` with `status === 'timeout'` | +| Status `limit_exceeded` (exit 7) | `MCodeRunError` with `status === 'limit_exceeded'` | +| Status `cancelled` (exit 130) or SDK cancellation | `MCodeAbortError` with `name === 'AbortError'` | +| Exit 141, non-JSON stdout, or exit without `exec.completed` | `MCodeProtocolError` | +| No CLI found | `MCodeNotFoundError` (includes the install command) | +| CLI too old, or an unsupported event `schemaVersion` | `MCodeVersionError` | +| Concurrent Turn on the same Thread | `ThreadBusyError` | + +`MCodeRunError` carries `result` (the full `ExecResult` when `exec.completed` was received), +`items`, `retryable` (from `result.error.retryable`) and `sessionId`; `MCodeAbortError` also carries +`sessionId`. `sessionId` is set whenever the CLI emitted at least one event (or the Thread came from +`resumeThread`), even without `exec.completed`. The SDK never retries automatically, to avoid +replaying writes with unknown outcomes; call `resumeThread(error.sessionId)` to continue. + +## Cancellation, timeouts and process cleanup + +- When `signal` aborts, the SDK sends `SIGINT` and the CLI ends the Turn as `cancelled`. If the + process has not exited after 10 seconds it receives `SIGTERM`, and `SIGKILL` 5 seconds later. +- `timeoutMs` is enforced by the CLI, which ends the run gracefully with status `timeout`; the SDK + only force-stops it at `timeoutMs + 30s`. +- **Only normal host exits are covered**: when the host emits `exit` (the event loop drains or + `process.exit()` is called), the SDK sends `SIGTERM` to any running CLI process, which the CLI + treats as a cancel. A host killed by a signal such as `SIGTERM` or `SIGINT` does not emit `exit`, + and its CLI processes keep running as orphans. The SDK installs no signal handlers and Phase 1 has + no `close()` / `dispose()`. Hosts that need reliable cleanup should abort in-flight runs from their + own signal handlers and wait for them, for example: + + ```ts + const controller = new AbortController(); + process.once('SIGTERM', () => controller.abort()); + await thread.run(prompt, { signal: controller.signal }).catch(() => undefined); + ``` + +- After the CLI exits, the SDK keeps reading stdout / stderr for at most about two seconds. If a + grandchild process inherited and holds the pipes, the SDK closes them and settles the Turn from + the events already received instead of waiting for the grandchild. +- An unconsumed `runStreamed()` stream still runs to completion and frees the Thread. + +> [!WARNING] +> +> **Windows**: Node.js cannot send a catchable `SIGINT` to a child process on Windows, so +> cancellation is a hard kill and the runtime may leave the Turn unfinished. Phase 1 only supports +> the hard kill; graceful cancellation needs a signal-independent cancel channel in the CLI. + +## Concurrency and data directories + +Concurrent `exec` processes sharing one data directory have had limited testing. Prefer a separate +`dataDir` per SDK instance (or per concurrent task), with authentication configured in that +directory beforehand. + +## Not available in Phase 1 + +- `outputSchema` (MiniMax-hosted models do not reliably follow the schema yet). +- Approval callbacks, steering a running Turn, continuing after an interrupt, and forking. +- A Python SDK. diff --git a/packages/code-sdk/RELEASING.md b/packages/code-sdk/RELEASING.md new file mode 100644 index 000000000..dd90c225e --- /dev/null +++ b/packages/code-sdk/RELEASING.md @@ -0,0 +1,106 @@ +# Releasing `@minimax-ai/code-sdk` + +The SDK is published manually by a maintainer to the public npm registry as +`@minimax-ai/code-sdk`, with the `latest` dist-tag only. It is versioned independently of the CLI. +This file is not part of the npm package. + +## Rules + +- Publish only to `https://registry.npmjs.org/`. `package.json#publishConfig` pins that registry and + public access, so `npm publish` does not follow a different default registry configured on your + machine. Always pass `--registry https://registry.npmjs.org/` explicitly as well. +- A published version is immutable. Never try to republish or overwrite an exact version; fix + problems in a new version. Prefer `npm deprecate` over `npm unpublish` for a broken release. +- Versions are stable `X.Y.Z`. There is no prerelease channel yet. +- Publish only from a commit on `main` that went through a reviewed pull request. + +## 1. Prepare the release in a pull request + +1. Update `packages/code-sdk/package.json#version`. +2. Add a section at the top of `packages/code-sdk/CHANGELOG.md`: + `## X.Y.Z · YYYY-MM-DD` with the user-visible changes. +3. If the minimum supported CLI, the Node.js range or the event `schemaVersion` changes, update + the compatibility table in the README. +4. Merge the pull request into `main` after the usual review and checks. + +## 2. Run the release check on a clean `main` + +```bash +git switch main +git pull --ff-only +pnpm install --frozen-lockfile +pnpm --filter @minimax-ai/code-sdk run release:check +``` + +`release:check` never publishes. It: + +1. requires a clean working tree on `main` equal to `origin/main`; +2. checks that the version is `X.Y.Z`, matches the first CHANGELOG section, and is not on npm yet; + that the package is not private, pins `publishConfig`, has no runtime dependencies and publishes + only `dist`, `src`, `README.md` and `CHANGELOG.md` (npm adds `package.json` and `LICENSE`); +3. builds, type-checks and runs the SDK test suite; +4. runs `npm pack`, rejects unexpected tarball entries, and scans every packed file for internal + host names, credentials, local user paths (`/Users/…`), other repository package paths and + internal package names; +5. installs the tarball into an empty project and checks both `import` and `require`; +6. prints the exact `npm publish` command for the checked tarball and the read-back commands. + +Use `-- --allow-non-main` only to rehearse on a feature branch; its output is not release-ready. +Use `-- --offline` to skip the npm lookup when the registry is unreachable. + +## 3. Publish + +Run the printed command yourself. It publishes the tarball that was just checked: + +```bash +npm login --registry https://registry.npmjs.org/ # if needed; 2FA is required +npm publish /tmp/code-sdk-release-XXXX/minimax-ai-code-sdk-X.Y.Z.tgz \ + --registry https://registry.npmjs.org/ --access public --tag latest +``` + +npm may keep a new version in validation for a while; `npm view` can return 404 until it is +available. + +## 4. Read back + +```bash +npm view @minimax-ai/code-sdk@X.Y.Z version dist.integrity --registry https://registry.npmjs.org/ +npm view @minimax-ai/code-sdk dist-tags --registry https://registry.npmjs.org/ +``` + +The integrity must equal the value printed by `release:check`, and `latest` must point to +`X.Y.Z`. If the integrity differs, stop: the registry holds different bytes than the checked +tarball. Investigate and publish a new version; never overwrite. + +## 5. Smoke test the published package + +```bash +smoke="$(mktemp -d)" && cd "$smoke" +echo '{"name":"code-sdk-smoke","private":true}' > package.json +npm install @minimax-ai/code-sdk@X.Y.Z --registry https://registry.npmjs.org/ --no-package-lock +node --input-type=module -e "const sdk = await import('@minimax-ai/code-sdk'); console.log(typeof sdk.MCode)" +node -e "console.log(typeof require('@minimax-ai/code-sdk').MCode)" +``` + +Both commands must print `function`. + +## 6. Optional: tag the release + +```bash +git tag -a code-sdk-vX.Y.Z -m "@minimax-ai/code-sdk X.Y.Z" +git push origin code-sdk-vX.Y.Z +``` + +The tag only records the source of a published version; no automation publishes from it. Never +move or delete a pushed release tag. + +## Failure handling + +- **`release:check` fails**: nothing was published. Fix it in a pull request and start over. +- **`npm publish` fails before upload** (authentication, network): fix the cause and run the same + command again. +- **Upload accepted but read-back fails or times out**: wait and repeat the read-back. Do not + publish again unless `npm view` confirms the version does not exist. +- **Read-back shows different integrity, or the smoke test fails**: the version is published and + cannot be replaced. Release a fixed version; deprecate the broken one with + `npm deprecate @minimax-ai/code-sdk@X.Y.Z ""` if users should avoid it. diff --git a/packages/code-sdk/package.json b/packages/code-sdk/package.json new file mode 100644 index 000000000..91b93bff5 --- /dev/null +++ b/packages/code-sdk/package.json @@ -0,0 +1,67 @@ +{ + "name": "@minimax-ai/code-sdk", + "version": "0.1.0", + "description": "TypeScript SDK for driving the local MiniMax Code (mcode) agent from Node.js.", + "keywords": [ + "minimax", + "minimax-code", + "mcode", + "agent", + "coding-agent", + "sdk" + ], + "homepage": "https://agent.minimax.io", + "bugs": { + "url": "https://github.com/MiniMax-AI/minimax-code/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MiniMax-AI/minimax-code.git", + "directory": "packages/code-sdk" + }, + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src", + "README.md", + "CHANGELOG.md" + ], + "sideEffects": false, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "engines": { + "node": ">=22.19 <23 || >=24.2 <27" + }, + "peerDependencies": { + "@minimax-ai/code": ">=0.7.0" + }, + "peerDependenciesMeta": { + "@minimax-ai/code": { + "optional": true + } + }, + "scripts": { + "clean:build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "pnpm run clean:build && tsc --build --force", + "typecheck": "tsc --noEmit -p tsconfig.check.json", + "test": "node ../../scripts/run-vitest-suite.mjs code-sdk", + "release:check": "node scripts/release-check.mjs" + }, + "dependencies": {}, + "devDependencies": { + "@minimax-ai/code": "link:test/fixtures/cli-package" + } +} diff --git a/packages/code-sdk/scripts/release-check.mjs b/packages/code-sdk/scripts/release-check.mjs new file mode 100644 index 000000000..eab3f2604 --- /dev/null +++ b/packages/code-sdk/scripts/release-check.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * Pre-publication check for @minimax-ai/code-sdk. It never publishes: on success it prints the + * `npm publish` command for a maintainer to run. See ../RELEASING.md. + * + * pnpm --filter @minimax-ai/code-sdk run release:check + * pnpm --filter @minimax-ai/code-sdk run release:check -- --allow-non-main # rehearsal only + * pnpm --filter @minimax-ai/code-sdk run release:check -- --offline # skip npm lookup + */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(packageDir, '../..'); +// The repository's own publication rules (also used by `check:source`); this script runs only +// inside the repository checkout, never from the published package. +const { embeddedCredential, internalSourceReference } = await import( + pathToFileURL(path.join(repoRoot, 'scripts/lib/public-text-policy.mjs')).href +); +const REGISTRY = 'https://registry.npmjs.org/'; +const RELEASE_BRANCH = 'main'; +const PUBLISHED_FILES = ['dist', 'src', 'README.md', 'CHANGELOG.md']; +const ALLOWED_ENTRY = + /^package\/(?:package\.json|README\.md|CHANGELOG\.md|LICENSE|(?:dist|src)\/[^/]+\.(?:js|js\.map|d\.ts|d\.ts\.map|ts))$/u; +const REQUIRED_ENTRIES = [ + 'package/package.json', + 'package/README.md', + 'package/CHANGELOG.md', + 'package/LICENSE', + 'package/dist/index.js', + 'package/dist/index.d.ts', +]; +/** Published text must not point into the repository, at a local machine, or at private names. */ +const PUBLIC_TEXT_RULES = [ + ['internal source reference', internalSourceReference], + ['embedded credential', embeddedCredential], + ['local user path', /(?:\/Users\/|[A-Za-z]:\\Users\\)/u], + ['repository package path', /\b(?:packages\/(?!code-sdk\b)[\w.-]+|third_party)\//u], + ['internal package name', /@minimax\/code\b|@mavis\//u], +]; + +const args = new Set(process.argv.slice(2).filter((arg) => arg !== '--')); +for (const arg of args) { + if (!['--allow-non-main', '--offline'].includes(arg)) fail(`Unknown option: ${arg}`); +} + +function fail(message) { + console.error(`✗ ${message}`); + process.exit(1); +} + +function step(message) { + console.log(`\n▸ ${message}`); +} + +function run(command, commandArgs, { cwd = repoRoot, capture = false, allowFailure = false } = {}) { + const result = spawnSync(command, commandArgs, { + cwd, + encoding: 'utf8', + stdio: capture || allowFailure ? ['ignore', 'pipe', 'pipe'] : 'inherit', + shell: process.platform === 'win32', + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0 && !allowFailure) { + fail( + `${command} ${commandArgs.join(' ')} failed${capture ? `: ${`${result.stdout}${result.stderr}`.trim()}` : ''}`, + ); + } + return result; +} + +function git(commandArgs) { + return run('git', commandArgs, { capture: true }).stdout.trim(); +} + +function listFiles(directory) { + return readdirSync(directory).flatMap((name) => { + const full = path.join(directory, name); + return statSync(full).isDirectory() ? listFiles(full) : [full]; + }); +} + +const manifest = JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')); +const { name, version } = manifest; +const spec = `${name}@${version}`; + +step('Checkout'); +const status = git(['status', '--porcelain']); +if (status) fail(`The working tree must be clean; found: ${status.split('\n')[0]}`); +const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']); +const head = git(['rev-parse', 'HEAD']); +const remoteMain = run('git', ['rev-parse', '--verify', '--quiet', `origin/${RELEASE_BRANCH}`], { + capture: true, + allowFailure: true, +}).stdout.trim(); +if (branch !== RELEASE_BRANCH || head !== remoteMain) { + const detail = `on ${branch} at ${head.slice(0, 12)}; origin/${RELEASE_BRANCH} is ${remoteMain.slice(0, 12) || 'unknown'}`; + if (!args.has('--allow-non-main')) { + fail(`Release from an up-to-date ${RELEASE_BRANCH} (git fetch first): ${detail}`); + } + console.log(`! Rehearsal only (--allow-non-main): ${detail}`); +} else { + console.log(`✓ Clean ${RELEASE_BRANCH} at ${head}, equal to origin/${RELEASE_BRANCH}`); +} + +step('Version and manifest'); +if (!/^\d+\.\d+\.\d+$/u.test(version)) fail(`${name} version must be X.Y.Z, got ${version}`); +const changelog = readFileSync(path.join(packageDir, 'CHANGELOG.md'), 'utf8'); +const firstRelease = /^## (\S+) · \d{4}-\d{2}-\d{2}$/mu.exec(changelog)?.[1]; +if (firstRelease !== version) { + fail( + `CHANGELOG.md must start with "## ${version} · YYYY-MM-DD"; found ${firstRelease ?? 'nothing'}`, + ); +} +if (manifest.private) fail('package.json must not be private'); +if (manifest.publishConfig?.registry !== REGISTRY || manifest.publishConfig?.access !== 'public') { + fail(`publishConfig must be { access: "public", registry: "${REGISTRY}" }`); +} +if (JSON.stringify(manifest.files) !== JSON.stringify(PUBLISHED_FILES)) { + fail(`files must be ${JSON.stringify(PUBLISHED_FILES)}`); +} +if (Object.keys(manifest.dependencies ?? {}).length > 0) + fail('The SDK must not have runtime dependencies'); +console.log(`✓ ${spec}, CHANGELOG first section ${firstRelease}, publishConfig → ${REGISTRY}`); + +if (args.has('--offline')) { + console.log('! Skipped the npm registry lookup (--offline)'); +} else { + const view = run('npm', ['view', spec, 'version', '--registry', REGISTRY], { + capture: true, + allowFailure: true, + }); + if (view.status === 0 && view.stdout.trim() === version) { + fail(`${spec} is already published; published versions cannot be replaced, bump the version`); + } + if (view.status !== 0 && !/E404|404 Not Found/u.test(`${view.stdout}${view.stderr}`)) { + fail(`Could not query ${REGISTRY}: ${`${view.stdout}${view.stderr}`.trim()}`); + } + console.log(`✓ ${spec} is not yet on ${REGISTRY}`); +} + +step('Build, type check and tests'); +run('pnpm', ['--filter', name, 'run', 'build']); +run('pnpm', ['--filter', name, 'run', 'typecheck']); +run(process.execPath, ['scripts/run-vitest-suite.mjs', 'code-sdk']); + +step('Pack and scan the tarball'); +const outDir = mkdtempSync(path.join(tmpdir(), 'code-sdk-release-')); +const packed = JSON.parse( + run('npm', ['pack', '--json', '--pack-destination', outDir], { cwd: packageDir, capture: true }) + .stdout, +)[0]; +const tarball = path.join(outDir, packed.filename); +const entries = run('tar', ['-tzf', tarball], { capture: true }) + .stdout.split(/\r?\n/u) + .filter((entry) => entry && !entry.endsWith('/')); +const unexpected = entries.filter( + (entry) => !ALLOWED_ENTRY.test(entry) || entry.split('/').includes('..'), +); +if (unexpected.length) fail(`Unexpected tarball entries: ${unexpected.join(', ')}`); +const missing = REQUIRED_ENTRIES.filter((entry) => !entries.includes(entry)); +if (missing.length) fail(`Missing tarball entries: ${missing.join(', ')}`); +const extracted = path.join(outDir, 'extracted'); +mkdirSync(extracted); +run('tar', ['-xzf', tarball, '-C', extracted]); +const findings = []; +for (const file of listFiles(path.join(extracted, 'package'))) { + const text = readFileSync(file, 'utf8'); + for (const [label, pattern] of PUBLIC_TEXT_RULES) { + if (pattern.test(text)) findings.push(`${path.relative(extracted, file)}: ${label}`); + } +} +if (findings.length) + fail(`The tarball would publish private information:\n ${findings.join('\n ')}`); +const integrity = `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`; +console.log(`✓ ${entries.length} files, all within the public surface and free of private text`); +console.log(`✓ ${packed.filename} ${integrity}`); + +step('Install smoke test from the tarball'); +const project = path.join(outDir, 'smoke'); +mkdirSync(project); +writeFileSync(path.join(project, 'package.json'), '{"name":"code-sdk-smoke","private":true}\n'); +run( + 'npm', + ['install', tarball, '--no-audit', '--no-fund', '--ignore-scripts', '--no-package-lock'], + { + cwd: project, + capture: true, + }, +); +const smoke = run( + process.execPath, + [ + '--input-type=module', + '-e', + `const esm = await import('${name}'); + const { createRequire } = await import('node:module'); + const cjs = createRequire(process.cwd() + '/package.json')('${name}'); + for (const api of [esm, cjs]) { + if (typeof api.MCode !== 'function' || typeof api.MIN_CLI_VERSION !== 'string') process.exit(1); + } + console.log('import and require OK');`, + ], + { cwd: project, capture: true }, +); +console.log(`✓ ${smoke.stdout.trim()}`); + +console.log(`\n✓ ${spec} is ready. The tarball was checked but nothing was published.`); +console.log('\nPublish it yourself (npm login with 2FA required), from this directory:\n'); +console.log(` npm publish ${tarball} --registry ${REGISTRY} --access public --tag latest`); +console.log( + `\nThen read it back:\n\n npm view ${spec} version dist.integrity --registry ${REGISTRY} # expect ${integrity}`, +); +console.log(` npm view ${name} dist-tags --registry ${REGISTRY} # expect latest: ${version}`); diff --git a/packages/code-sdk/src/args.ts b/packages/code-sdk/src/args.ts new file mode 100644 index 000000000..bede909b7 --- /dev/null +++ b/packages/code-sdk/src/args.ts @@ -0,0 +1,98 @@ +import { resolve } from 'node:path'; + +import { MCodeInvocationError } from './errors.js'; +import type { RunOptions, ThreadOptions } from './types.js'; + +const PERMISSIONS = new Set(['smart', 'full', 'off']); +const CONTEXT_MODES = new Set(['standard', 'lightweight']); +const PROMPT_MODES = new Set(['tui', 'coding', 'work']); + +export interface ExecArgsInput { + readonly configPath?: string; + readonly thread: ThreadOptions; + /** Existing Session to continue; `null` creates a new Session. */ + readonly sessionId: string | null; + readonly run: RunOptions; + readonly systemPromptFile?: string; + readonly appendSystemPromptFile?: string; +} + +/** + * Builds `mcode exec` argv. The prompt travels over stdin and system prompts through temporary + * files, so argv carries only SDK-generated flags, identifiers and paths. Values use the + * `--flag=value` form so a value starting with `-` is never parsed as a flag. + */ +export function buildExecArgs(input: ExecArgsInput): string[] { + const { thread, run } = input; + const args = [ + 'exec', + '--output-format=stream-json', + '--input=-', + '--input-format=json', + `--cwd=${resolve(thread.cwd ?? process.cwd())}`, + ]; + const push = (flag: string, value: string | undefined): void => { + if (value !== undefined) args.push(`${flag}=${value}`); + }; + push('--model', thread.model); + push('--effort', thread.effort); + push('--permission', thread.permission); + push('--prompt-mode', thread.promptMode); + push('--config', input.configPath); + // A continued Session keeps the context mode it was created with. + if (input.sessionId === null) push('--mode', thread.mode); + else push('--session', input.sessionId); + push('--system-prompt-file', input.systemPromptFile); + push('--append-system-prompt-file', input.appendSystemPromptFile); + for (const file of run.files ?? []) push('--file', file); + if (run.timeoutMs !== undefined) push('--timeout', `${String(run.timeoutMs)}ms`); + if (run.maxSteps !== undefined) push('--max-steps', String(run.maxSteps)); + return args; +} + +export function validateThreadOptions(options: ThreadOptions, resuming: boolean): void { + if (options.permission !== undefined && !PERMISSIONS.has(options.permission)) { + throw new MCodeInvocationError( + `permission must be smart, full or off; received ${JSON.stringify(options.permission)}.`, + ); + } + if (options.mode !== undefined && !CONTEXT_MODES.has(options.mode)) { + throw new MCodeInvocationError( + `mode must be standard or lightweight; received ${JSON.stringify(options.mode)}.`, + ); + } + if (resuming && options.mode === 'lightweight') { + throw new MCodeInvocationError( + 'mode "lightweight" requires a new Thread; it cannot be resumed.', + ); + } + if (options.promptMode !== undefined && !PROMPT_MODES.has(options.promptMode)) { + throw new MCodeInvocationError( + `promptMode must be tui, coding or work; received ${JSON.stringify(options.promptMode)}.`, + ); + } +} + +/** + * Largest accepted `timeoutMs`: the SDK backstop timer (`timeoutMs + 30s`) must fit in a Node + * timer, whose delay is a signed 32-bit integer (about 24.8 days). + */ +export const MAX_TIMEOUT_MS = 2 ** 31 - 1 - 30_000; + +export function validateRunOptions(options: RunOptions): void { + if ( + options.timeoutMs !== undefined && + (!isPositiveInteger(options.timeoutMs) || options.timeoutMs > MAX_TIMEOUT_MS) + ) { + throw new MCodeInvocationError( + `timeoutMs must be a positive integer number of milliseconds no greater than ${String(MAX_TIMEOUT_MS)}.`, + ); + } + if (options.maxSteps !== undefined && !isPositiveInteger(options.maxSteps)) { + throw new MCodeInvocationError('maxSteps must be a positive integer.'); + } +} + +function isPositiveInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} diff --git a/packages/code-sdk/src/cli-resolver.ts b/packages/code-sdk/src/cli-resolver.ts new file mode 100644 index 000000000..617862921 --- /dev/null +++ b/packages/code-sdk/src/cli-resolver.ts @@ -0,0 +1,185 @@ +import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'; + +import { MCodeNotFoundError } from './errors.js'; +import { findProjectBin, parseCmdShim } from './project-bin.js'; + +export { parseCmdShim, parseShellShim } from './project-bin.js'; + +export const CLI_PACKAGE_NAME = '@minimax-ai/code'; + +/** How to start the CLI. `cmd` is the Windows `.cmd` fallback that needs cmd.exe. */ +export interface ResolvedCli { + readonly kind: 'node' | 'exec' | 'cmd'; + /** JS entry (`node`), executable (`exec`) or `.cmd` file (`cmd`). */ + readonly path: string; + readonly source: 'option' | 'package' | 'project' | 'path'; +} + +export interface SpawnCommand { + readonly command: string; + readonly args: string[]; + readonly windowsVerbatimArguments?: boolean; +} + +export interface ResolveCliOptions { + readonly bin?: string; + readonly env: Readonly>; + /** Base paths for resolving the optional `@minimax-ai/code` peer dependency. */ + readonly packageSearchFrom?: readonly string[]; + /** Directory where the upward `node_modules/.bin/mcode` search starts. */ + readonly projectSearchFrom?: string; + readonly platform?: NodeJS.Platform; +} + +/** + * Resolution order: explicit `bin` → installed `@minimax-ai/code` package → nearest + * `node_modules/.bin/mcode` above the working directory → `mcode` on PATH. + */ +export function resolveCli(options: ResolveCliOptions): ResolvedCli { + const platform = options.platform ?? process.platform; + if (options.bin !== undefined) { + const looksLikePath = isAbsolute(options.bin) || /[\\/]/u.test(options.bin); + const found = looksLikePath + ? existsSync(options.bin) + ? resolve(options.bin) + : undefined + : findOnPath(options.bin, options.env, platform); + if (!found) + throw new MCodeNotFoundError(`The configured mcode bin was not found: ${options.bin}.`); + return fromExecutable(found, 'option', platform); + } + const fromPackage = resolvePackageEntry( + options.packageSearchFrom ?? [import.meta.url, join(process.cwd(), 'package.json')], + ); + if (fromPackage) return { kind: 'node', path: fromPackage, source: 'package' }; + const fromProject = findProjectBin(options.projectSearchFrom ?? process.cwd(), platform); + if (fromProject) return fromProject; + const onPath = findOnPath('mcode', options.env, platform); + if (onPath) return fromExecutable(onPath, 'path', platform); + throw new MCodeNotFoundError( + `Could not find the mcode CLI (${CLI_PACKAGE_NAME} is not installed, no node_modules/.bin/mcode was found above the working directory, and mcode is not on PATH).`, + ); +} + +/** Resolves `bin.mcode` of an installed `@minimax-ai/code` package to an existing JS file. */ +export function resolvePackageEntry(searchFrom: readonly string[]): string | undefined { + for (const base of searchFrom) { + let manifestPath: string; + try { + manifestPath = createRequire(base).resolve(`${CLI_PACKAGE_NAME}/package.json`); + } catch { + continue; + } + try { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + bin?: string | Record; + }; + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.mcode; + if (!bin) continue; + const entry = resolve(dirname(manifestPath), bin); + if (isFile(entry)) return entry; + } catch { + continue; + } + } + return undefined; +} + +export function findOnPath( + name: string, + env: Readonly>, + platform: NodeJS.Platform = process.platform, +): string | undefined { + const pathValue = readEnv(env, 'PATH', platform) ?? ''; + const separator = platform === 'win32' ? ';' : delimiter; + const extensions = + platform === 'win32' + ? extname(name) + ? [''] + : (readEnv(env, 'PATHEXT', platform) ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : ['']; + for (const directory of pathValue.split(separator)) { + if (!directory) continue; + for (const extension of extensions) { + const candidate = join(directory.replace(/^"(.*)"$/u, '$1'), name + extension); + if (isFile(candidate) && (platform === 'win32' || isExecutable(candidate))) return candidate; + } + } + return undefined; +} + +function fromExecutable( + path: string, + source: ResolvedCli['source'], + platform: NodeJS.Platform, +): ResolvedCli { + const extension = extname(path).toLowerCase(); + if (extension === '.js' || extension === '.mjs' || extension === '.cjs') { + return { kind: 'node', path, source }; + } + if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) { + const entry = parseCmdShim(readFileSync(path, 'utf8'), path); + return entry ? { kind: 'node', path: entry, source } : { kind: 'cmd', path, source }; + } + return { kind: 'exec', path, source }; +} + +export function buildSpawnCommand( + cli: ResolvedCli, + args: readonly string[], + env: Readonly> = process.env, +): SpawnCommand { + if (cli.kind === 'node') return { command: process.execPath, args: [cli.path, ...args] }; + if (cli.kind === 'exec') return { command: cli.path, args: [...args] }; + const line = [escapeCmdCommand(cli.path), ...args.map((arg) => escapeCmdArgument(arg))].join(' '); + return { + command: readEnv(env, 'ComSpec', 'win32') ?? 'cmd.exe', + args: ['/d', '/s', '/c', `"${line}"`], + windowsVerbatimArguments: true, + }; +} + +const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/gu; + +export function escapeCmdCommand(value: string): string { + return value.replace(CMD_META_CHARS, '^$1'); +} + +/** + * Quotes one argument for cmd.exe running a batch shim. Batch files re-parse `%*`, so meta + * characters are caret-escaped twice (same strategy as cross-spawn). + */ +export function escapeCmdArgument(value: string): string { + let quoted = value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\*)$/u, '$1$1'); + quoted = `"${quoted}"`; + return quoted.replace(CMD_META_CHARS, '^$1').replace(CMD_META_CHARS, '^$1'); +} + +function readEnv( + env: Readonly>, + key: string, + platform: NodeJS.Platform, +): string | undefined { + if (platform !== 'win32') return env[key]; + const match = Object.keys(env).find((name) => name.toUpperCase() === key.toUpperCase()); + return match === undefined ? undefined : env[match]; +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} diff --git a/packages/code-sdk/src/errors.ts b/packages/code-sdk/src/errors.ts new file mode 100644 index 000000000..f54e47c66 --- /dev/null +++ b/packages/code-sdk/src/errors.ts @@ -0,0 +1,206 @@ +import type { ExecErrorCategory, ExecResult, ExecResultStatus, ThreadItem } from './types.js'; + +/** Exit codes of `mcode exec` (`MINIMAX_CODE_EXEC_EXIT_CODES` in the CLI). */ +export const MCODE_EXEC_EXIT_CODES = Object.freeze({ + success: 0, + invocation: 2, + config: 3, + runtime: 4, + timeout: 6, + limit: 7, + internal: 70, + cancelled: 130, + brokenPipe: 141, +}); + +export const MCODE_INSTALL_HINT = 'npm install @minimax-ai/code@latest'; + +export interface MCodeErrorDetails { + readonly exitCode?: number | null; + readonly signal?: NodeJS.Signals | null; + /** Tail of the CLI stderr (at most 64 KiB), for diagnostics only. */ + readonly stderr?: string; + readonly cause?: unknown; + /** Session observed in the event stream (or known from `resumeThread`). */ + readonly sessionId?: string; +} + +/** Base class for every error raised by the SDK. */ +export class MCodeError extends Error { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly stderr: string; + + constructor(message: string, details: MCodeErrorDetails = {}) { + super(message, details.cause === undefined ? undefined : { cause: details.cause }); + this.name = 'MCodeError'; + this.exitCode = details.exitCode ?? null; + this.signal = details.signal ?? null; + this.stderr = details.stderr ?? ''; + } +} + +/** The CLI rejected the invocation (exit 2) or the SDK rejected options before starting it. */ +export class MCodeInvocationError extends MCodeError { + constructor(message: string, details?: MCodeErrorDetails) { + super(message, details); + this.name = 'MCodeInvocationError'; + } +} + +export interface MCodeRunErrorInit extends MCodeErrorDetails { + readonly status: Exclude; + readonly category?: ExecErrorCategory; + readonly result?: ExecResult; + readonly items?: readonly ThreadItem[]; +} + +/** The Turn ended with a non-success status, or the CLI exited with a run failure code. */ +export class MCodeRunError extends MCodeError { + readonly status: Exclude; + readonly category: ExecErrorCategory | undefined; + readonly code: string | undefined; + readonly retryable: boolean; + /** Complete CLI result when `exec.completed` was received. */ + readonly result: ExecResult | undefined; + readonly items: readonly ThreadItem[]; + /** Session to pass to `resumeThread`; known whenever the CLI emitted at least one event. */ + readonly sessionId: string | undefined; + + constructor(message: string, init: MCodeRunErrorInit) { + super(message, init); + this.name = 'MCodeRunError'; + this.status = init.status; + this.category = init.result?.error?.category ?? init.category; + this.code = init.result?.error?.code; + this.retryable = init.result?.error?.retryable ?? false; + this.result = init.result; + this.items = init.items ?? []; + this.sessionId = init.result?.sessionId ?? init.sessionId; + } +} + +/** The Turn was cancelled (AbortSignal or exit 130). `name` is `AbortError`. */ +export class MCodeAbortError extends MCodeError { + readonly result: ExecResult | undefined; + readonly items: readonly ThreadItem[]; + /** Session to pass to `resumeThread`; known whenever the CLI emitted at least one event. */ + readonly sessionId: string | undefined; + + constructor( + message: string, + init: MCodeErrorDetails & { result?: ExecResult; items?: readonly ThreadItem[] } = {}, + ) { + super(message, init); + this.name = 'AbortError'; + this.result = init.result; + this.items = init.items ?? []; + this.sessionId = init.result?.sessionId ?? init.sessionId; + } +} + +/** The CLI stdout violated the JSONL contract, or the output pipe broke (exit 141). */ +export class MCodeProtocolError extends MCodeError { + constructor(message: string, details?: MCodeErrorDetails) { + super(message, details); + this.name = 'MCodeProtocolError'; + } +} + +/** No usable `mcode` CLI could be located. */ +export class MCodeNotFoundError extends MCodeError { + readonly installCommand = MCODE_INSTALL_HINT; + + constructor(message: string, details?: MCodeErrorDetails) { + super(`${message} Install it with \`${MCODE_INSTALL_HINT}\` or pass \`bin\`.`, details); + this.name = 'MCodeNotFoundError'; + } +} + +/** The CLI is older than the SDK minimum or speaks an unsupported `schemaVersion`. */ +export class MCodeVersionError extends MCodeError { + readonly version: string | undefined; + + constructor(message: string, details: MCodeErrorDetails & { version?: string } = {}) { + super(message, details); + this.name = 'MCodeVersionError'; + this.version = details.version; + } +} + +/** A Thread already has a Turn in progress. The SDK does not queue Turns. */ +export class ThreadBusyError extends MCodeError { + constructor(threadId: string | null) { + super(`Thread ${threadId ?? '(new)'} already has a Turn in progress.`); + this.name = 'ThreadBusyError'; + } +} + +const STATUS_BY_EXIT_CODE: Readonly< + Record; category?: ExecErrorCategory }> +> = { + [MCODE_EXEC_EXIT_CODES.config]: { status: 'failed', category: 'config' }, + [MCODE_EXEC_EXIT_CODES.runtime]: { status: 'failed', category: 'runtime' }, + [MCODE_EXEC_EXIT_CODES.timeout]: { status: 'timeout' }, + [MCODE_EXEC_EXIT_CODES.limit]: { status: 'limit_exceeded' }, + [MCODE_EXEC_EXIT_CODES.internal]: { status: 'failed', category: 'internal' }, +}; + +/** Maps a terminal non-success result to the SDK error the caller receives. */ +export function errorForResult( + result: ExecResult, + items: readonly ThreadItem[], + details: MCodeErrorDetails, +): MCodeError { + if (result.status === 'cancelled') { + return new MCodeAbortError('The MCode run was cancelled.', { ...details, result, items }); + } + const status = result.status === 'succeeded' ? 'failed' : result.status; + const reason = result.error?.message ?? `status ${result.status}`; + return new MCodeRunError(`The MCode run ended with ${status}: ${reason}`, { + ...details, + status, + result, + items, + }); +} + +/** Maps a process exit that produced no `exec.completed` event. */ +export function errorForExit( + details: MCodeErrorDetails & { readonly items?: readonly ThreadItem[] }, +): MCodeError { + const { exitCode, items } = details; + const reason = lastLine(details.stderr) ?? `exit code ${String(exitCode ?? details.signal)}`; + if (exitCode === MCODE_EXEC_EXIT_CODES.invocation) { + return new MCodeInvocationError(`mcode rejected the invocation: ${reason}`, details); + } + if (exitCode === MCODE_EXEC_EXIT_CODES.cancelled) { + return new MCodeAbortError('The MCode run was cancelled.', { + ...details, + ...(items ? { items } : {}), + }); + } + if (exitCode === MCODE_EXEC_EXIT_CODES.brokenPipe) { + return new MCodeProtocolError(`mcode reported a broken output pipe: ${reason}`, details); + } + if (exitCode === MCODE_EXEC_EXIT_CODES.success) { + return new MCodeProtocolError('mcode exited without emitting exec.completed.', details); + } + const mapped = + exitCode === null || exitCode === undefined ? undefined : STATUS_BY_EXIT_CODE[exitCode]; + return new MCodeRunError(`The MCode run ended without a result: ${reason}`, { + ...details, + status: mapped?.status ?? 'failed', + ...(mapped ? (mapped.category ? { category: mapped.category } : {}) : { category: 'internal' }), + ...(items ? { items } : {}), + }); +} + +function lastLine(text: string | undefined): string | undefined { + const line = text + ?.split(/\r?\n/u) + .map((value) => value.trim()) + .filter(Boolean) + .at(-1); + return line || undefined; +} diff --git a/packages/code-sdk/src/exec-process.ts b/packages/code-sdk/src/exec-process.ts new file mode 100644 index 000000000..74643656b --- /dev/null +++ b/packages/code-sdk/src/exec-process.ts @@ -0,0 +1,323 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +import type { SpawnCommand } from './cli-resolver.js'; +import { + errorForExit, + MCodeAbortError, + MCodeError, + MCodeNotFoundError, + MCodeProtocolError, + MCodeRunError, + MCodeVersionError, +} from './errors.js'; +import { parseEventLine } from './protocol.js'; +import type { ThreadEvent, ThreadItem } from './types.js'; + +/** Cancellation and cleanup timings. Mutable only so tests can shorten them. */ +export const processTimings = { + /** SIGINT → SIGTERM. */ + interruptGraceMs: 10_000, + /** SIGTERM → SIGKILL. */ + terminateGraceMs: 5_000, + /** Extra time after `timeoutMs` before the SDK force-kills a CLI that did not stop itself. */ + backstopExtraMs: 30_000, + /** + * After the CLI exits, how long to keep reading stdout / stderr. A grandchild process that + * inherited the pipes can keep them open indefinitely; the streams are destroyed afterwards. + */ + exitDrainMs: 2_000, +}; + +const STDERR_TAIL_BYTES = 64 * 1024; + +const liveChildren = new Set(); +let exitHookInstalled = false; + +function trackChild(child: ChildProcess): void { + liveChildren.add(child); + if (exitHookInstalled) return; + exitHookInstalled = true; + // The CLI treats SIGTERM as a graceful cancel. Without this, children outlive the host. + process.once('exit', () => { + for (const live of liveChildren) { + try { + live.kill('SIGTERM'); + } catch { + // The child already exited. + } + } + }); +} + +export interface ExecProcessOptions { + readonly command: SpawnCommand; + readonly env: Readonly>; + readonly prompt: string; + readonly signal?: AbortSignal; + /** CLI-side timeout; enables the SDK backstop at `timeoutMs + backstopExtraMs`. */ + readonly timeoutMs?: number; + /** Called for each parsed event; return false to drop it from the stream. */ + readonly onEvent?: (event: ThreadEvent) => boolean; + /** Current normalized items, attached to errors raised without an `exec.completed` result. */ + readonly items?: () => readonly ThreadItem[]; + /** Session already known before the first event (resumed Threads). */ + readonly sessionId?: string; +} + +/** + * One `mcode exec` child process. Starts immediately and buffers parsed events until consumed, + * so an unconsumed stream still runs to completion and releases its resources. + */ +export class ExecProcess { + private readonly child: ChildProcess; + private readonly queue: ThreadEvent[] = []; + private wake: (() => void) | undefined; + private finished = false; + private failure: Error | undefined; + private completed = false; + private exited = false; + private abortRequested = false; + private backstopFired = false; + private stderrTail = ''; + private sessionId: string | undefined; + private readonly timers = new Set(); + private readonly onAbort = (): void => this.interrupt(); + private exitCodeValue: number | null = null; + private resolveDone!: () => void; + /** Resolves once the child has exited (or failed to start) and resources are released. */ + readonly done = new Promise((resolve) => { + this.resolveDone = resolve; + }); + + constructor(private readonly options: ExecProcessOptions) { + const { command } = options; + this.child = spawn(command.command, command.args, { + env: options.env as NodeJS.ProcessEnv, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + ...(command.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + this.sessionId = options.sessionId; + trackChild(this.child); + this.attach(); + options.signal?.addEventListener('abort', this.onAbort, { once: true }); + // `abort` listeners added after the signal fired are never called. + if (options.signal?.aborted) this.interrupt(); + if (options.timeoutMs !== undefined) { + this.schedule(options.timeoutMs + processTimings.backstopExtraMs, () => { + // After exit only the pipe drain is pending; that is not a timeout. + if (this.exited) return; + this.backstopFired = true; + this.kill('SIGKILL'); + }); + } + } + + get stderr(): string { + return this.stderrTail; + } + + get exitCode(): number | null { + return this.exitCodeValue; + } + + /** Yields events in CLI order; throws on process or protocol failure. */ + async *events(): AsyncGenerator { + try { + for (;;) { + const next = this.queue.shift(); + if (next) { + yield next; + continue; + } + if (this.finished) { + if (this.failure) throw this.failure; + return; + } + await new Promise((resolve) => { + this.wake = resolve; + }); + } + } finally { + // The consumer stopped early: cancel the Turn instead of leaving the CLI running. + if (!this.exited) this.interrupt(); + } + } + + /** SIGINT, then SIGTERM after the grace period, then SIGKILL. */ + interrupt(): void { + if (this.exited || this.abortRequested) return; + this.abortRequested = true; + // Windows cannot deliver a catchable SIGINT; kill() terminates the process there. + this.kill(process.platform === 'win32' ? 'SIGTERM' : 'SIGINT'); + this.schedule(processTimings.interruptGraceMs, () => { + this.kill('SIGTERM'); + this.schedule(processTimings.terminateGraceMs, () => this.kill('SIGKILL')); + }); + } + + private attach(): void { + const { child } = this; + let lines = 0; + let streamsOpen = 2; + let settled = false; + let exit: { code: number | null; signal: NodeJS.Signals | null } | undefined; + const settle = (force = false): void => { + if (settled || !exit || (streamsOpen > 0 && !force)) return; + settled = true; + this.settle(exit.code, exit.signal); + }; + + child.stdin?.on('error', () => { + // EPIPE when the CLI exits before reading stdin; the exit code reports the cause. + }); + child.stdin?.end(JSON.stringify({ prompt: this.options.prompt })); + + child.stderr?.setEncoding('utf8').on('data', (chunk: string) => { + this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_TAIL_BYTES); + }); + child.stderr?.once('close', () => { + streamsOpen -= 1; + settle(); + }); + + if (!child.stdout) throw new MCodeError('mcode stdout is unavailable.'); + const reader = createInterface({ input: child.stdout, crlfDelay: Infinity }); + reader.on('line', (line) => { + if (this.finished || this.completed) return; + lines += 1; + try { + const event = parseEventLine(line); + if (!event) return; + this.sessionId ??= event.sessionId; + if (this.options.onEvent?.(event) === false) return; + this.push(event); + if (event.type === 'exec.completed') this.onCompleted(); + } catch (error) { + this.fail(withDiagnostics(error, this.stderrTail, lines)); + this.interrupt(); + } + }); + reader.once('close', () => { + streamsOpen -= 1; + settle(); + }); + + child.once('error', (error: NodeJS.ErrnoException) => { + this.exited = true; + this.fail( + error.code === 'ENOENT' || error.code === 'EACCES' + ? new MCodeNotFoundError(`Could not start mcode (${error.code}).`, { cause: error }) + : new MCodeError(`Could not start mcode: ${error.message}`, { cause: error }), + ); + this.cleanup(); + }); + child.once('exit', (code, signal) => { + this.exited = true; + exit = { code, signal }; + settle(); + if (settled) return; + // Normally the pipes close right after exit; a grandchild holding them must not stall us. + // Settling clears this timer together with the other process timers. + this.schedule(processTimings.exitDrainMs, () => { + child.stdout?.destroy(); + child.stderr?.destroy(); + reader.close(); + settle(true); + }); + }); + } + + private onCompleted(): void { + this.completed = true; + // exec.completed is terminal; a CLI that lingers afterwards is force-stopped. + this.schedule(processTimings.terminateGraceMs, () => this.kill('SIGKILL')); + } + + private settle(code: number | null, signal: NodeJS.Signals | null): void { + this.exitCodeValue = code; + this.cleanup(); + if (this.finished) return; + if (this.completed) { + this.finish(); + return; + } + const items = this.options.items?.() ?? []; + const details = { + exitCode: code, + signal, + stderr: this.stderrTail, + items, + ...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }), + }; + if (this.backstopFired) { + this.fail( + new MCodeRunError('The MCode run exceeded timeoutMs and was force-stopped by the SDK.', { + ...details, + status: 'timeout', + }), + ); + return; + } + if (this.abortRequested && code !== 2) { + this.fail(new MCodeAbortError('The MCode run was cancelled.', details)); + return; + } + this.fail(errorForExit(details)); + } + + private push(event: ThreadEvent): void { + this.queue.push(event); + this.signalConsumer(); + } + + private fail(error: Error): void { + if (this.finished) return; + this.failure = error; + this.finish(); + } + + private finish(): void { + this.finished = true; + this.signalConsumer(); + } + + private signalConsumer(): void { + const wake = this.wake; + this.wake = undefined; + wake?.(); + } + + private kill(signal: NodeJS.Signals): void { + if (this.exited) return; + try { + this.child.kill(signal); + } catch { + // Already gone. + } + } + + private schedule(delayMs: number, action: () => void): void { + const timer = setTimeout(() => { + this.timers.delete(timer); + action(); + }, delayMs); + this.timers.add(timer); + } + + private cleanup(): void { + for (const timer of this.timers) clearTimeout(timer); + this.timers.clear(); + liveChildren.delete(this.child); + this.options.signal?.removeEventListener('abort', this.onAbort); + this.resolveDone(); + } +} + +function withDiagnostics(error: unknown, stderr: string, line: number): Error { + const message = `${error instanceof Error ? error.message : String(error)} (stdout line ${String(line)})`; + const details = { stderr, cause: error instanceof Error ? error.cause : undefined }; + if (error instanceof MCodeVersionError) return new MCodeVersionError(message, details); + return new MCodeProtocolError(message, details); +} diff --git a/packages/code-sdk/src/index.ts b/packages/code-sdk/src/index.ts new file mode 100644 index 000000000..d5224e833 --- /dev/null +++ b/packages/code-sdk/src/index.ts @@ -0,0 +1,16 @@ +export { MCode } from './mcode.js'; +export { Thread } from './thread.js'; +export { + MCODE_EXEC_EXIT_CODES, + MCodeAbortError, + MCodeError, + MCodeInvocationError, + MCodeNotFoundError, + MCodeProtocolError, + MCodeRunError, + MCodeVersionError, + ThreadBusyError, +} from './errors.js'; +export { MAX_TIMEOUT_MS } from './args.js'; +export { MIN_CLI_VERSION } from './version.js'; +export type * from './types.js'; diff --git a/packages/code-sdk/src/items.ts b/packages/code-sdk/src/items.ts new file mode 100644 index 000000000..d5e0fadd7 --- /dev/null +++ b/packages/code-sdk/src/items.ts @@ -0,0 +1,74 @@ +import type { ExecItem, RawToolCall, ThreadEvent, ThreadItem } from './types.js'; + +interface ItemState { + type: ExecItem['type']; + text: string; + toolCall: RawToolCall | undefined; + completed: boolean; + /** Serialized last snapshot, used to drop identical repeated `item.updated` events. */ + snapshot: string; +} + +/** + * Folds CLI `item.*` events into SDK items. + * + * - Items are merged by `id`; events of different items may interleave in any order. + * - Snapshot updates (no `contentDelta`) identical to the previous snapshot are reported as + * duplicates so the stream can drop them. Text deltas are never deduplicated. + * - Items without `item.completed` are marked `incomplete` in {@link items}. + */ +export class ItemAccumulator { + private readonly states = new Map(); + + /** Applies one event. Returns false when the event is a duplicate snapshot update. */ + apply(event: ThreadEvent): boolean { + if ( + event.type !== 'item.started' && + event.type !== 'item.updated' && + event.type !== 'item.completed' + ) { + return true; + } + const { item } = event; + const snapshot = item.contentDelta === undefined ? JSON.stringify(item) : ''; + let state = this.states.get(item.id); + if ( + state && + event.type === 'item.updated' && + snapshot && + state.snapshot === snapshot && + !state.completed + ) { + return false; + } + if (!state) { + state = { type: item.type, text: '', toolCall: undefined, completed: false, snapshot: '' }; + this.states.set(item.id, state); + } + state.snapshot = snapshot; + if (item.contentDelta !== undefined) state.text += item.contentDelta; + if (item.content !== undefined) state.text = item.content; + if (item.toolCall !== undefined) state.toolCall = item.toolCall; + if (event.type === 'item.completed') state.completed = true; + return true; + } + + /** Known items in order of first appearance; unknown item types stay stream-only. */ + items(): ThreadItem[] { + return [...this.states.entries()].flatMap(([id, state]) => toThreadItem(id, state) ?? []); + } +} + +function toThreadItem(id: string, state: ItemState): ThreadItem | undefined { + const incomplete = state.completed ? {} : { incomplete: true as const }; + switch (state.type) { + case 'tool_call': + return { id, type: 'tool_call', toolCall: state.toolCall ?? { id }, ...incomplete }; + case 'reasoning': + return { id, type: 'reasoning', text: state.text, ...incomplete }; + case 'agent_message': + return { id, type: 'agent_message', text: state.text, ...incomplete }; + default: + return undefined; + } +} diff --git a/packages/code-sdk/src/mcode.ts b/packages/code-sdk/src/mcode.ts new file mode 100644 index 000000000..891bd27be --- /dev/null +++ b/packages/code-sdk/src/mcode.ts @@ -0,0 +1,60 @@ +import { resolve } from 'node:path'; + +import { validateThreadOptions } from './args.js'; +import { resolveCli, type ResolvedCli } from './cli-resolver.js'; +import { MCodeInvocationError } from './errors.js'; +import { Thread, type ThreadHost } from './thread.js'; +import type { MCodeOptions, ThreadOptions } from './types.js'; +import { checkCliVersion } from './version.js'; + +/** Entry point of the SDK. One instance resolves and version-checks the CLI once. */ +export class MCode { + private cli: Promise | undefined; + private readonly host: ThreadHost; + + constructor(private readonly options: MCodeOptions = {}) { + this.host = { + ...(options.configPath === undefined ? {} : { configPath: resolve(options.configPath) }), + childEnv: () => this.childEnv(), + prepareCli: () => this.prepareCli(), + }; + } + + /** Starts a new Thread. The Session is created by its first Turn. */ + startThread(options: ThreadOptions = {}): Thread { + validateThreadOptions(options, false); + return new Thread(this.host, options, null); + } + + /** Continues an existing Session, for example one persisted from `thread.id`. */ + resumeThread(id: string, options: ThreadOptions = {}): Thread { + if (typeof id !== 'string' || !id.trim()) { + throw new MCodeInvocationError('resumeThread requires a non-empty Session id.'); + } + validateThreadOptions(options, true); + return new Thread(this.host, options, id.trim()); + } + + private childEnv(): Record { + const env: Record = { ...(this.options.env ?? process.env) }; + if (this.options.dataDir !== undefined) env.MINIMAX_DATA_DIR = resolve(this.options.dataDir); + return env; + } + + private prepareCli(): Promise { + this.cli ??= (async () => { + const env = this.childEnv(); + const cli = resolveCli({ + ...(this.options.bin === undefined ? {} : { bin: this.options.bin }), + env, + }); + await checkCliVersion(cli, env); + return cli; + })().catch((error: unknown) => { + // Allow a later run to succeed after the user installs or upgrades the CLI. + this.cli = undefined; + throw error; + }); + return this.cli; + } +} diff --git a/packages/code-sdk/src/project-bin.ts b/packages/code-sdk/src/project-bin.ts new file mode 100644 index 000000000..33cec3414 --- /dev/null +++ b/packages/code-sdk/src/project-bin.ts @@ -0,0 +1,96 @@ +import { closeSync, openSync, readSync, realpathSync, statSync } from 'node:fs'; +import { dirname, extname, join, resolve } from 'node:path'; + +import type { ResolvedCli } from './cli-resolver.js'; + +const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); +const SHIM_HEAD_BYTES = 4096; + +/** + * Finds a project-local `mcode` launcher by walking up from `startDir` and checking each + * `node_modules/.bin`. Any CLI package that provides the `mcode` bin is accepted, so projects + * can pin whichever MCode distribution they install. + */ +export function findProjectBin( + startDir: string, + platform: NodeJS.Platform = process.platform, +): ResolvedCli | undefined { + const names = platform === 'win32' ? ['mcode.cmd', 'mcode.ps1'] : ['mcode']; + let directory = resolve(startDir); + for (;;) { + for (const name of names) { + const candidate = join(directory, 'node_modules', '.bin', name); + if (!isFile(candidate)) continue; + const resolved = resolveLauncher(candidate, platform); + if (resolved) return resolved; + } + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +/** Prefers the JS entry behind a launcher so it runs with `process.execPath`. */ +function resolveLauncher(launcher: string, platform: NodeJS.Platform): ResolvedCli | undefined { + const extension = extname(launcher).toLowerCase(); + if (platform === 'win32') { + const head = readHead(launcher); + const entry = + extension === '.cmd' ? parseCmdShim(head, launcher) : parseShellShim(head, launcher); + if (entry) return { kind: 'node', path: entry, source: 'project' }; + // PowerShell shims are only usable through their JS entry. + return extension === '.cmd' ? { kind: 'cmd', path: launcher, source: 'project' } : undefined; + } + // npm links `.bin/mcode` to the package's JS entry; pnpm writes a shell shim. + const real = realpathSync(launcher); + if (JS_EXTENSIONS.has(extname(real).toLowerCase())) { + return { kind: 'node', path: real, source: 'project' }; + } + const head = readHead(real); + if (/^#!.*\bnode\b/u.test(head.split('\n', 1)[0] ?? '')) { + return { kind: 'node', path: real, source: 'project' }; + } + const entry = parseShellShim(head, launcher); + return { kind: entry ? 'node' : 'exec', path: entry ?? launcher, source: 'project' }; +} + +/** + * Extracts the JS entry from an npm / pnpm generated `.cmd` shim, so the CLI can run with + * `process.execPath` instead of cmd.exe. + */ +export function parseCmdShim(content: string, shimPath: string): string | undefined { + const match = /"%~?dp0%?\\?([^"%]+\.(?:c|m)?js)"/iu.exec(content); + if (!match?.[1]) return undefined; + const entry = join(dirname(shimPath), ...match[1].split(/[\\/]/u).filter(Boolean)); + return isFile(entry) ? entry : undefined; +} + +/** + * Extracts the JS entry from a pnpm / npm generated POSIX shell or PowerShell shim, which + * reference it as `"$basedir/.js"`. + */ +export function parseShellShim(content: string, shimPath: string): string | undefined { + const match = /"\$basedir[\\/]([^"$]+\.(?:c|m)?js)"/u.exec(content); + if (!match?.[1]) return undefined; + const entry = join(dirname(shimPath), ...match[1].split(/[\\/]/u).filter(Boolean)); + return isFile(entry) ? entry : undefined; +} + +function readHead(path: string): string { + const buffer = Buffer.alloc(SHIM_HEAD_BYTES); + const fd = openSync(path, 'r'); + try { + const bytes = readSync(fd, buffer, 0, SHIM_HEAD_BYTES, 0); + return buffer.subarray(0, bytes).toString('utf8'); + } finally { + closeSync(fd); + } +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} diff --git a/packages/code-sdk/src/protocol.ts b/packages/code-sdk/src/protocol.ts new file mode 100644 index 000000000..fcc694160 --- /dev/null +++ b/packages/code-sdk/src/protocol.ts @@ -0,0 +1,63 @@ +import { MCodeProtocolError, MCodeVersionError } from './errors.js'; +import type { ThreadEvent } from './types.js'; + +export const SUPPORTED_SCHEMA_VERSION = 1; + +/** + * Parses one stdout line. Returns `undefined` for blank lines. + * + * Unknown event types are passed through; only the shared envelope and the fields the SDK relies + * on are validated. + */ +export function parseEventLine(line: string): ThreadEvent | undefined { + const text = line.trim(); + if (!text) return undefined; + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new MCodeProtocolError(`mcode stdout is not JSON: ${truncate(text)}`, { cause: error }); + } + if (!isRecord(value)) { + throw new MCodeProtocolError(`mcode stdout line is not a JSON object: ${truncate(text)}`); + } + if (value.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { + throw new MCodeVersionError( + `Unsupported mcode event schemaVersion ${JSON.stringify(value.schemaVersion)}; this SDK supports ${String(SUPPORTED_SCHEMA_VERSION)}.`, + ); + } + if (typeof value.type !== 'string' || typeof value.sessionId !== 'string') { + throw new MCodeProtocolError(`mcode event is missing type or sessionId: ${truncate(text)}`); + } + if (value.type.startsWith('item.') && !isItem(value.item)) { + throw new MCodeProtocolError( + `mcode ${value.type} event has an invalid item: ${truncate(text)}`, + ); + } + if (value.type === 'exec.completed' && !isResult(value.result)) { + throw new MCodeProtocolError(`mcode exec.completed has an invalid result: ${truncate(text)}`); + } + return value as unknown as ThreadEvent; +} + +function isItem(value: unknown): boolean { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string'; +} + +function isResult(value: unknown): boolean { + return ( + isRecord(value) && + value.schemaVersion === SUPPORTED_SCHEMA_VERSION && + typeof value.status === 'string' && + typeof value.sessionId === 'string' && + typeof value.turnId === 'string' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function truncate(text: string): string { + return text.length > 200 ? `${text.slice(0, 200)}…` : text; +} diff --git a/packages/code-sdk/src/thread.ts b/packages/code-sdk/src/thread.ts new file mode 100644 index 000000000..e01447586 --- /dev/null +++ b/packages/code-sdk/src/thread.ts @@ -0,0 +1,186 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { buildExecArgs, validateRunOptions } from './args.js'; +import { buildSpawnCommand, type ResolvedCli } from './cli-resolver.js'; +import { errorForResult, MCodeAbortError, MCodeProtocolError, ThreadBusyError } from './errors.js'; +import { ExecProcess } from './exec-process.js'; +import { ItemAccumulator } from './items.js'; +import type { + ExecResult, + RunOptions, + StreamedTurn, + ThreadEvent, + ThreadOptions, + Turn, +} from './types.js'; + +/** What a Thread needs from its owning {@link MCode} client. */ +export interface ThreadHost { + readonly configPath?: string; + childEnv(): Record; + prepareCli(): Promise; +} + +interface StartedTurn { + readonly process: ExecProcess; + readonly items: ItemAccumulator; + /** Resolves after the process exited, temp files are removed and the Thread is free. */ + readonly released: Promise; +} + +/** + * A conversation with the Agent. The SDK only holds the Session id; every Turn starts a new + * `mcode exec` process and continues the Session with `--session`. + */ +export class Thread { + private sessionId: string | null; + private busy = false; + + constructor( + private readonly host: ThreadHost, + private readonly options: ThreadOptions, + id: string | null, + ) { + this.sessionId = id; + } + + /** Session id. Available after the first event of the first Turn, even if that Turn fails. */ + get id(): string | null { + return this.sessionId; + } + + /** Runs one Turn. Resolves only when it succeeds; otherwise rejects with an `MCodeError`. */ + async run(input: string, options: RunOptions = {}): Promise { + const started = await this.start(input, options); + const { process, items } = started; + let result: ExecResult | undefined; + for await (const event of this.stream(started)) { + if (event.type === 'exec.completed') result = event.result; + } + if (!result) throw new MCodeProtocolError('mcode finished without exec.completed.'); + const finalItems = items.items(); + if (result.status !== 'succeeded') { + throw errorForResult(result, finalItems, { + exitCode: process.exitCode, + stderr: process.stderr, + }); + } + return { + status: 'succeeded', + finalResponse: finalResponseOf(result.output), + items: finalItems, + ...(result.usage ? { usage: result.usage } : {}), + ...(result.model ? { model: result.model } : {}), + sessionId: result.sessionId, + turnId: result.turnId, + durationMs: result.durationMs, + result, + }; + } + + /** + * Runs one Turn and yields CLI events as they arrive. `turn.failed` is an ordinary event; the + * iterator throws only for process or protocol failures. Breaking out of the loop cancels the + * Turn and waits for the CLI to exit. + */ + async runStreamed(input: string, options: RunOptions = {}): Promise { + return { events: this.stream(await this.start(input, options)) }; + } + + /** Ends (normally, by error or by `return()`) only after the Turn's resources are released. */ + private async *stream(started: StartedTurn): AsyncGenerator { + try { + yield* started.process.events(); + } finally { + await started.released; + } + } + + private async start(input: string, options: RunOptions): Promise { + if (this.busy) throw new ThreadBusyError(this.sessionId); + this.busy = true; + let tempDir: string | undefined; + const release = async (): Promise => { + this.busy = false; + if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + }; + try { + if (typeof input !== 'string') throw new TypeError('input must be a string.'); + validateRunOptions(options); + throwIfAborted(options.signal, this.sessionId); + const cli = await this.host.prepareCli(); + throwIfAborted(options.signal, this.sessionId); + const promptFiles: { systemPromptFile?: string; appendSystemPromptFile?: string } = {}; + if ( + this.options.systemPrompt !== undefined || + this.options.appendSystemPrompt !== undefined + ) { + tempDir = await mkdtemp(join(tmpdir(), 'mcode-sdk-')); + if (this.options.systemPrompt !== undefined) { + promptFiles.systemPromptFile = await writePrivate( + join(tempDir, 'system-prompt.txt'), + this.options.systemPrompt, + ); + } + if (this.options.appendSystemPrompt !== undefined) { + promptFiles.appendSystemPromptFile = await writePrivate( + join(tempDir, 'append-system-prompt.txt'), + this.options.appendSystemPrompt, + ); + } + } + // The signal may fire while temporary prompt files are written. + throwIfAborted(options.signal, this.sessionId); + const env = this.host.childEnv(); + const args = buildExecArgs({ + ...(this.host.configPath === undefined ? {} : { configPath: this.host.configPath }), + thread: this.options, + sessionId: this.sessionId, + run: options, + ...promptFiles, + }); + const items = new ItemAccumulator(); + const process = new ExecProcess({ + command: buildSpawnCommand(cli, args, env), + env, + prompt: input, + ...(options.signal ? { signal: options.signal } : {}), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + onEvent: (event: ThreadEvent) => { + this.sessionId ??= event.sessionId; + return items.apply(event); + }, + items: () => items.items(), + ...(this.sessionId === null ? {} : { sessionId: this.sessionId }), + }); + // Started eagerly so an unconsumed runStreamed() still frees the Thread on exit. + const released = process.done.then(release); + return { process, items, released }; + } catch (error) { + // release() never rejects, so the original error is preserved. + await release(); + throw error; + } + } +} + +function throwIfAborted(signal: AbortSignal | undefined, sessionId: string | null): void { + if (signal?.aborted) { + throw new MCodeAbortError('The MCode run was cancelled before it started.', { + cause: signal.reason, + ...(sessionId === null ? {} : { sessionId }), + }); + } +} + +async function writePrivate(path: string, content: string): Promise { + await writeFile(path, content, { encoding: 'utf8', mode: 0o600 }); + return path; +} + +function finalResponseOf(output: unknown): string { + if (typeof output === 'string') return output; + return output === undefined || output === null ? '' : JSON.stringify(output); +} diff --git a/packages/code-sdk/src/types.ts b/packages/code-sdk/src/types.ts new file mode 100644 index 000000000..59da8aeb8 --- /dev/null +++ b/packages/code-sdk/src/types.ts @@ -0,0 +1,224 @@ +/** + * Public types for the `mcode exec --output-format stream-json` contract (schemaVersion 1). + * + * The SDK owns these types so the published package does not depend on CLI internals. A + * repository contract test keeps them aligned with the events the CLI actually emits. + */ + +export type ExecResultStatus = 'succeeded' | 'failed' | 'timeout' | 'cancelled' | 'limit_exceeded'; + +export type ExecErrorCategory = 'config' | 'runtime' | 'internal'; + +export interface ExecError { + readonly category: ExecErrorCategory; + readonly code?: string; + readonly message: string; + readonly retryable?: boolean; +} + +export interface ModelIdentity { + readonly providerId: string; + readonly modelId: string; + readonly variant?: string; + readonly providerSource?: string; + readonly providerKind?: string; + readonly protocol?: string; +} + +/** + * Token usage. `totalTokens = inputTokens + outputTokens`; cache buckets are reported separately + * and are not included in `totalTokens`. `reasoningTokens` may overlap `outputTokens`. + */ +export interface Usage { + readonly totalTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly reasoningTokens?: number; + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; +} + +export type UsageSource = 'completed_responses' | 'analytics_fallback' | 'unavailable'; + +/** Final result of one `mcode exec` run (`exec.completed.result`). */ +export interface ExecResult { + readonly schemaVersion: 1; + readonly type: 'exec.result'; + readonly runId: string; + readonly sessionId: string; + readonly turnId: string; + readonly status: ExecResultStatus; + /** Final answer. Phase 1 does not support output schemas, so this is a string when present. */ + readonly output?: unknown; + readonly error?: ExecError; + readonly model?: ModelIdentity; + readonly usage?: Usage; + readonly usageSource?: UsageSource; + readonly usageIncomplete?: boolean; + readonly durationMs: number; +} + +/** + * Raw tool call snapshot forwarded from the Runtime. + * + * @experimental `status` is an internal numeric enum and `input` / `output` are tool-specific + * structures. Neither is covered by the SDK compatibility promise. + */ +export interface RawToolCall { + readonly id?: string; + readonly name?: string; + readonly status?: number; + readonly input?: unknown; + readonly output?: unknown; + readonly durationMs?: number; + readonly [key: string]: unknown; +} + +export type ExecItemType = 'agent_message' | 'reasoning' | 'tool_call'; + +/** Item payload carried by `item.*` events exactly as the CLI emits it. */ +export interface ExecItem { + readonly id: string; + readonly type: ExecItemType; + /** Full text, present on `item.completed` for messages and reasoning. */ + readonly content?: string; + /** Incremental text, present on `item.started` / `item.updated` for messages and reasoning. */ + readonly contentDelta?: string; + /** @experimental See {@link RawToolCall}. */ + readonly toolCall?: RawToolCall; +} + +interface ThreadEventBase { + readonly schemaVersion: 1; + readonly sequence: number; + readonly timestampMs: number; + readonly runId: string; + readonly sessionId: string; + readonly turnId: string; +} + +export type ExecStartedEvent = ThreadEventBase & { readonly type: 'exec.started' }; +export type SessionStartedEvent = ThreadEventBase & { + readonly type: 'session.started' | 'session.resumed'; +}; +export type TurnStartedEvent = ThreadEventBase & { readonly type: 'turn.started' }; +export type ItemEvent = ThreadEventBase & { + readonly type: 'item.started' | 'item.updated' | 'item.completed'; + readonly item: ExecItem; +}; +export type TurnCompletedEvent = ThreadEventBase & { + readonly type: 'turn.completed'; + readonly model?: ModelIdentity; + readonly usage?: Usage; + readonly usageSource?: UsageSource; + readonly usageIncomplete?: boolean; + readonly durationMs: number; +}; +export type TurnFailedEvent = ThreadEventBase & { + readonly type: 'turn.failed'; + readonly status: Exclude; + readonly error?: ExecError; + readonly durationMs: number; +}; +export type ExecCompletedEvent = ThreadEventBase & { + readonly type: 'exec.completed'; + readonly result: ExecResult; +}; + +/** + * Events yielded by `runStreamed()`, unchanged from the CLI stream. + * + * Event types added by newer CLI versions are passed through at runtime as well; keep a + * `default` branch when switching on `event.type`. + */ +export type ThreadEvent = + | ExecStartedEvent + | SessionStartedEvent + | TurnStartedEvent + | ItemEvent + | TurnCompletedEvent + | TurnFailedEvent + | ExecCompletedEvent; + +interface ThreadItemBase { + readonly id: string; + /** True when the run ended before the CLI sent `item.completed` for this item. */ + readonly incomplete?: true; +} + +export interface AgentMessageItem extends ThreadItemBase { + readonly type: 'agent_message'; + readonly text: string; +} + +export interface ReasoningItem extends ThreadItemBase { + readonly type: 'reasoning'; + readonly text: string; +} + +export interface ToolCallItem extends ThreadItemBase { + readonly type: 'tool_call'; + /** @experimental See {@link RawToolCall}. */ + readonly toolCall: RawToolCall; +} + +/** SDK-normalized item, merged by `id` across all `item.*` events of one Turn. */ +export type ThreadItem = AgentMessageItem | ReasoningItem | ToolCallItem; + +/** Result of a successful `thread.run()`. */ +export interface Turn { + readonly status: 'succeeded'; + readonly finalResponse: string; + readonly items: ThreadItem[]; + readonly usage?: Usage; + readonly model?: ModelIdentity; + readonly sessionId: string; + readonly turnId: string; + readonly durationMs: number; + readonly result: ExecResult; +} + +export type PermissionPolicy = 'smart' | 'full' | 'off'; +export type ContextMode = 'standard' | 'lightweight'; +export type PromptMode = 'tui' | 'coding' | 'work'; + +export interface MCodeOptions { + /** Explicit `mcode` executable or JS entry. Skips package and PATH lookup. */ + readonly bin?: string; + /** Data directory for Sessions and config; passed to the CLI as `MINIMAX_DATA_DIR`. */ + readonly dataDir?: string; + /** Child process environment. Replaces `process.env` when provided. */ + readonly env?: Readonly>; + /** Runtime config file, passed as `--config`. */ + readonly configPath?: string; +} + +export interface ThreadOptions { + /** Workspace directory, passed as `--cwd`. Defaults to the host process cwd. */ + readonly cwd?: string; + /** Model as `provider/model`, for example `minimax/MiniMax-M3`. */ + readonly model?: string; + readonly effort?: string; + readonly permission?: PermissionPolicy; + /** Context mode for a new Thread. `lightweight` cannot be used with `resumeThread`. */ + readonly mode?: ContextMode; + readonly promptMode?: PromptMode; + /** Replaces the main Agent identity prompt for each Turn. */ + readonly systemPrompt?: string; + /** Appended after the main Agent identity prompt for each Turn. */ + readonly appendSystemPrompt?: string; +} + +export interface RunOptions { + /** Files attached to this Turn (`--file`). Relative paths resolve against `cwd`. */ + readonly files?: readonly string[]; + readonly signal?: AbortSignal; + /** Run timeout handled by the CLI (`--timeout ms`). */ + readonly timeoutMs?: number; + /** Maximum assistant steps (`--max-steps`). */ + readonly maxSteps?: number; +} + +export interface StreamedTurn { + readonly events: AsyncGenerator; +} diff --git a/packages/code-sdk/src/version.ts b/packages/code-sdk/src/version.ts new file mode 100644 index 000000000..309137da4 --- /dev/null +++ b/packages/code-sdk/src/version.ts @@ -0,0 +1,109 @@ +import { spawn } from 'node:child_process'; + +import { buildSpawnCommand, type ResolvedCli } from './cli-resolver.js'; +import { MCODE_INSTALL_HINT, MCodeNotFoundError, MCodeVersionError } from './errors.js'; + +/** Minimum supported CLI version (provisional until verified on the public 0.7.x release). */ +export const MIN_CLI_VERSION = '0.7.0'; + +const VERSION_TIMEOUT_MS = 30_000; + +export interface ParsedVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease?: string; +} + +export function parseVersion(text: string): ParsedVersion | undefined { + const match = /(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/u.exec(text); + if (!match) return undefined; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + ...(match[4] ? { prerelease: match[4] } : {}), + }; +} + +/** + * Compares only `major.minor.patch`. Preview builds such as `0.7.0-preview.N` satisfy `0.7.0` + * because the preview channel ships the same exec contract as its release line. + */ +export function satisfiesMinimum(version: ParsedVersion, minimum: ParsedVersion): boolean { + if (version.major !== minimum.major) return version.major > minimum.major; + if (version.minor !== minimum.minor) return version.minor > minimum.minor; + return version.patch >= minimum.patch; +} + +/** Runs `mcode --version` and rejects CLIs older than {@link MIN_CLI_VERSION}. */ +export async function checkCliVersion( + cli: ResolvedCli, + env: Readonly>, +): Promise { + const output = await readVersionOutput(cli, env); + const parsed = parseVersion(output); + const minimum = parseVersion(MIN_CLI_VERSION) as ParsedVersion; + if (!parsed) { + throw new MCodeVersionError( + `Could not read the mcode version from: ${output.trim() || '(empty)'}.`, + ); + } + const version = + output + .trim() + .split(/\s+/u) + .find((part) => parseVersion(part)) ?? output.trim(); + if (!satisfiesMinimum(parsed, minimum)) { + throw new MCodeVersionError( + `mcode ${version} is older than the minimum ${MIN_CLI_VERSION} required by @minimax-ai/code-sdk. Upgrade with \`${MCODE_INSTALL_HINT}\`.`, + { version }, + ); + } + return version; +} + +function readVersionOutput( + cli: ResolvedCli, + env: Readonly>, +): Promise { + const command = buildSpawnCommand(cli, ['--version'], env); + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const child = spawn(command.command, command.args, { + env: env as NodeJS.ProcessEnv, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + ...(command.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + const timer = setTimeout(() => child.kill('SIGKILL'), VERSION_TIMEOUT_MS); + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', (error: NodeJS.ErrnoException) => { + clearTimeout(timer); + reject( + error.code === 'ENOENT' || error.code === 'EACCES' + ? new MCodeNotFoundError(`Could not start mcode at ${cli.path}.`, { cause: error }) + : error, + ); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code === 0) { + resolve(stdout); + return; + } + reject( + new MCodeVersionError( + `\`mcode --version\` failed (${String(code ?? signal)}); cannot verify CLI compatibility.`, + { exitCode: code, signal, stderr: stderr.slice(-4096) }, + ), + ); + }); + }); +} diff --git a/packages/code-sdk/test/args.test.ts b/packages/code-sdk/test/args.test.ts new file mode 100644 index 000000000..2850617c3 --- /dev/null +++ b/packages/code-sdk/test/args.test.ts @@ -0,0 +1,152 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { MAX_TIMEOUT_MS, validateRunOptions } from '../src/args.js'; +import { MCode, MCodeInvocationError, MCodeRunError } from '../src/index.js'; +import { FAKE_MCODE, fakeClient, fixture, readRecord, tempDir } from './helpers.js'; + +describe('CLI argument mapping', () => { + it('maps Thread, run and client options to exec flags, stdin and environment', async () => { + const dir = tempDir(); + const record = `${dir}/record.json`; + const mcode = fakeClient( + { FAKE_MCODE_FIXTURE: fixture('success-tools'), FAKE_MCODE_RECORD: record }, + { dataDir: `${dir}/data`, configPath: `${dir}/config.yaml` }, + ); + const thread = mcode.startThread({ + cwd: dir, + model: 'minimax/MiniMax-M3', + effort: 'high', + permission: 'full', + mode: 'standard', + promptMode: 'coding', + systemPrompt: 'You are a release bot.', + appendSystemPrompt: '--- keep answers short', + }); + + await thread.run('-p "quoted" $HOME `rm -rf`', { + files: ['./ci.log', '-starts-with-dash.txt'], + timeoutMs: 600_000, + maxSteps: 12, + }); + + const { argv, stdin, env, promptFiles } = readRecord(record); + expect(argv.slice(0, 4)).toEqual([ + 'exec', + '--output-format=stream-json', + '--input=-', + '--input-format=json', + ]); + expect(argv).toEqual( + expect.arrayContaining([ + `--cwd=${resolve(dir)}`, + '--model=minimax/MiniMax-M3', + '--effort=high', + '--permission=full', + '--mode=standard', + '--prompt-mode=coding', + `--config=${resolve(dir, 'config.yaml')}`, + '--file=./ci.log', + '--file=-starts-with-dash.txt', + '--timeout=600000ms', + '--max-steps=12', + ]), + ); + // Free text never reaches argv: the prompt goes over stdin, system prompts via files. + expect(argv.join(' ')).not.toContain('quoted'); + expect(argv.join(' ')).not.toContain('release bot'); + expect(JSON.parse(stdin)).toEqual({ prompt: '-p "quoted" $HOME `rm -rf`' }); + expect(promptFiles).toEqual({ + 'system-prompt-file': 'You are a release bot.', + 'append-system-prompt-file': '--- keep answers short', + }); + expect(env.MINIMAX_DATA_DIR).toBe(resolve(dir, 'data')); + + // Temporary prompt files are already removed when run() resolves. + expect(existsSync(promptFileOf(record))).toBe(false); + }); + + it('removes prompt files before a failed run rejects and before a stream ends', async () => { + const record = `${tempDir()}/record.json`; + const failing = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('failed-upstream'), + FAKE_MCODE_EXIT: '4', + FAKE_MCODE_RECORD: record, + }); + await expect(failing.startThread({ systemPrompt: 'x' }).run('go')).rejects.toBeInstanceOf( + MCodeRunError, + ); + expect(existsSync(promptFileOf(record))).toBe(false); + + const streaming = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('resumed'), + FAKE_MCODE_RECORD: record, + }); + const { events } = await streaming.startThread({ systemPrompt: 'y' }).runStreamed('go'); + for await (const event of events) void event; + expect(existsSync(promptFileOf(record))).toBe(false); + }); + + it('omits optional flags and the data dir variable when not configured', async () => { + const record = `${tempDir()}/record.json`; + const mcode = new MCode({ + bin: FAKE_MCODE, + env: { + PATH: process.env.PATH, + FAKE_MCODE_FIXTURE: fixture('success-tools'), + FAKE_MCODE_RECORD: record, + }, + }); + + await mcode.startThread().run('hi'); + + const { argv, env } = readRecord(record); + expect(argv).toEqual([ + 'exec', + '--output-format=stream-json', + '--input=-', + '--input-format=json', + `--cwd=${process.cwd()}`, + ]); + expect(env.MINIMAX_DATA_DIR).toBeNull(); + }); + + it('rejects invalid options before starting a process', async () => { + const record = `${tempDir()}/record.json`; + const mcode = fakeClient({ FAKE_MCODE_RECORD: record }); + + expect(() => mcode.resumeThread('mvs_1', { mode: 'lightweight' })).toThrow( + MCodeInvocationError, + ); + expect(() => mcode.resumeThread(' ')).toThrow(MCodeInvocationError); + expect(() => mcode.startThread({ permission: 'ask' as unknown as 'smart' })).toThrow( + MCodeInvocationError, + ); + const thread = mcode.startThread(); + await expect(thread.run('x', { timeoutMs: 0 })).rejects.toBeInstanceOf(MCodeInvocationError); + await expect(thread.run('x', { maxSteps: 1.5 })).rejects.toBeInstanceOf(MCodeInvocationError); + expect(existsSync(record)).toBe(false); + // A rejected Turn does not leave the Thread busy. + await expect(thread.run('x', { timeoutMs: -1 })).rejects.toBeInstanceOf(MCodeInvocationError); + }); + + it('rejects timeoutMs values whose SDK backstop would overflow a Node timer', async () => { + expect(() => validateRunOptions({ timeoutMs: MAX_TIMEOUT_MS })).not.toThrow(); + // 2_200_000_000 + 30s used to overflow and fire the force-kill after ~1ms. + const record = `${tempDir()}/record.json`; + await expect( + fakeClient({ FAKE_MCODE_RECORD: record }) + .startThread() + .run('x', { timeoutMs: 2_200_000_000 }), + ).rejects.toBeInstanceOf(MCodeInvocationError); + expect(existsSync(record)).toBe(false); + }); +}); + +function promptFileOf(record: string): string { + const arg = readRecord(record).argv.find((value) => value.startsWith('--system-prompt-file=')); + if (!arg) throw new Error('no --system-prompt-file argument recorded'); + return arg.slice('--system-prompt-file='.length); +} diff --git a/packages/code-sdk/test/cli-contract.test.ts b/packages/code-sdk/test/cli-contract.test.ts new file mode 100644 index 000000000..4aa57537c --- /dev/null +++ b/packages/code-sdk/test/cli-contract.test.ts @@ -0,0 +1,115 @@ +/** + * Contract between the SDK's self-held types and the mcode CLI source. + * + * Imports the CLI's exit-code policy and its real JSONL projector from `packages/tui` (both are + * free of runtime dependencies) and checks that the SDK parses, normalizes and maps them as + * documented. Excluded from `tsc` because the CLI sources type-check only inside their package. + */ +import { describe, expect, it } from 'vitest'; + +import { ExecEventProjector } from '../../tui/src/headless/events.js'; +import { + exitCodeForExecResult, + MINIMAX_CODE_EXEC_EXIT_CODES, +} from '../../tui/src/headless/exit-policy.js'; +import { errorForExit, errorForResult, MCODE_EXEC_EXIT_CODES } from '../src/errors.js'; +import { MCodeAbortError, MCodeRunError } from '../src/index.js'; +import { ItemAccumulator } from '../src/items.js'; +import { parseEventLine } from '../src/protocol.js'; +import type { ExecResult } from '../src/types.js'; + +const ids = { runId: 'exec_turn_1', sessionId: 'mvs_contract', turnId: 'turn_1' }; + +function result(overrides: Partial): ExecResult { + return { + schemaVersion: 1, + type: 'exec.result', + ...ids, + status: 'succeeded', + durationMs: 5, + ...overrides, + }; +} + +describe('mcode exec contract', () => { + it('uses the CLI exit codes', () => { + expect(MCODE_EXEC_EXIT_CODES).toEqual(MINIMAX_CODE_EXEC_EXIT_CODES); + }); + + it.each([ + result({ status: 'failed', error: { category: 'config', message: 'bad config' } }), + result({ + status: 'failed', + error: { category: 'runtime', message: 'upstream', retryable: true }, + }), + result({ status: 'failed', error: { category: 'internal', message: 'bug' } }), + result({ status: 'timeout' }), + result({ status: 'limit_exceeded' }), + result({ status: 'cancelled' }), + ])( + 'maps CLI result $status/$error.category and its exit code to the same SDK error', + (cliResult) => { + const fromResult = errorForResult(cliResult, [], {}); + const fromExit = errorForExit({ exitCode: exitCodeForExecResult(cliResult as never) }); + + expect(fromExit.constructor).toBe(fromResult.constructor); + if (fromResult instanceof MCodeRunError && fromExit instanceof MCodeRunError) { + expect(fromExit.status).toBe(fromResult.status); + expect(fromExit.category).toBe(fromResult.category); + } else { + expect(fromResult).toBeInstanceOf(MCodeAbortError); + } + }, + ); + + it('parses and normalizes the events produced by the CLI projector', () => { + const projector = new ExecEventProjector({ ...ids, resumed: false, nowMs: () => 1 }); + const toolStart = { id: 'call_1', name: 'bash', status: 1, input: { command: 'ls' } }; + const toolDone = { ...toolStart, status: 2, output: { content: [] } }; + const internal = [ + { type: 'delta', role: 'assistant', messageId: 'm1', thinking: 'plan', content: 'Hel' }, + { type: 'delta', role: 'assistant', messageId: 'm1', content: 'lo', toolCalls: [toolStart] }, + { + type: 'message', + message: { + role: 'assistant', + id: 'm1', + thinking: 'plan', + content: 'Hello', + toolCalls: [toolDone], + }, + }, + ]; + const finalResult = result({ status: 'succeeded', output: 'Hello', usage: { totalTokens: 3 } }); + const projected = [ + ...internal.flatMap((event) => projector.project(event as never)), + ...projector.complete(finalResult as never), + ]; + + const items = new ItemAccumulator(); + const parsed = projected.map((event) => parseEventLine(JSON.stringify(event))); + for (const event of parsed) if (event) items.apply(event); + + expect(parsed.map((event) => event?.type)).toEqual([ + 'exec.started', + 'session.started', + 'turn.started', + 'item.started', + 'item.started', + 'item.updated', + 'item.started', + 'item.completed', + 'item.completed', + 'item.completed', + 'turn.completed', + 'exec.completed', + ]); + expect(items.items()).toEqual([ + { id: 'm1:reasoning', type: 'reasoning', text: 'plan' }, + { id: 'm1:message', type: 'agent_message', text: 'Hello' }, + { id: 'call_1', type: 'tool_call', toolCall: toolDone }, + ]); + const completed = parsed.at(-1); + expect(completed?.type === 'exec.completed' && completed.result).toEqual(finalResult); + }); +}); diff --git a/packages/code-sdk/test/cli-resolution.test.ts b/packages/code-sdk/test/cli-resolution.test.ts new file mode 100644 index 000000000..b861c4591 --- /dev/null +++ b/packages/code-sdk/test/cli-resolution.test.ts @@ -0,0 +1,169 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { MCode, MCodeNotFoundError, MCodeVersionError } from '../src/index.js'; +import { + buildSpawnCommand, + escapeCmdArgument, + findOnPath, + parseCmdShim, + resolveCli, + resolvePackageEntry, +} from '../src/cli-resolver.js'; +import { parseVersion, satisfiesMinimum } from '../src/version.js'; +import { FAKE_MCODE, fakeClient, fixture, tempDir } from './helpers.js'; + +const isWindows = process.platform === 'win32'; + +/** Creates a platform-native `mcode` launcher in a fresh directory that runs the fake CLI. */ +function installLauncher(name = 'mcode'): string { + const dir = tempDir(); + if (isWindows) { + mkdirSync(join(dir, 'node_modules', 'fake'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', 'fake', 'mcode.mjs'), readFileSync(FAKE_MCODE)); + writeFileSync( + join(dir, `${name}.cmd`), + `@ECHO off\r\n"%_prog%" "%dp0%\\node_modules\\fake\\mcode.mjs" %*\r\n`, + ); + } else { + const launcher = join(dir, name); + writeFileSync(launcher, `#!/bin/sh\nexec "${process.execPath}" "${FAKE_MCODE}" "$@"\n`); + chmodSync(launcher, 0o755); + } + return dir; +} + +describe('CLI resolution', () => { + it('prefers an explicit bin', () => { + expect(resolveCli({ bin: FAKE_MCODE, env: {} })).toEqual({ + kind: 'node', + path: FAKE_MCODE, + source: 'option', + }); + }); + + it('resolves bin.mcode of an installed @minimax-ai/code package and runs it with Node', async () => { + // The workspace links a test double for the optional peer dependency. + const sdkSource = new URL('../src/cli-resolver.ts', import.meta.url).href; + expect(resolvePackageEntry([sdkSource])).toBe(FAKE_MCODE); + + const turn = await new MCode({ + env: { PATH: '', FAKE_MCODE_FIXTURE: fixture('resumed') }, + }) + .startThread() + .run('hi'); + expect(turn.finalResponse).toMatch(/^I wrote exactly/u); + }); + + it('falls back to mcode on PATH', async () => { + const dir = installLauncher(); + const env = { PATH: dir, PATHEXT: '.CMD' }; + + const cli = resolveCli({ env, packageSearchFrom: [], projectSearchFrom: tempDir() }); + expect(cli.source).toBe('path'); + // Windows .cmd shims resolve to their JS entry, avoiding cmd.exe. + expect(cli.kind).toBe(isWindows ? 'node' : 'exec'); + expect(findOnPath('mcode', env)).toBeTruthy(); + + const turn = await new MCode({ + bin: 'mcode', + env: { ...env, FAKE_MCODE_FIXTURE: fixture('resumed') }, + }) + .startThread() + .run('hi'); + expect(turn.status).toBe('succeeded'); + }); + + it('throws MCodeNotFoundError with an install hint when nothing is found', async () => { + const empty = tempDir(); + expect(() => + resolveCli({ env: { PATH: empty }, packageSearchFrom: [], projectSearchFrom: empty }), + ).toThrow(MCodeNotFoundError); + const error = await new MCode({ bin: join(empty, 'missing-mcode'), env: { PATH: empty } }) + .startThread() + .run('x') + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(MCodeNotFoundError); + expect((error as Error).message).toContain('npm install @minimax-ai/code@latest'); + }); + + it('extracts the JS entry from npm and pnpm cmd shims', () => { + const dir = tempDir(); + mkdirSync(join(dir, 'node_modules', '@minimax-ai', 'code', 'dist'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', '@minimax-ai', 'code', 'dist', 'index.js'), ''); + const shim = join(dir, 'mcode.cmd'); + const npm = `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@minimax-ai\\code\\dist\\index.js" %*`; + const pnpm = `"%~dp0\\node_modules\\@minimax-ai\\code\\dist\\index.js" %*`; + const entry = join(dir, 'node_modules', '@minimax-ai', 'code', 'dist', 'index.js'); + + expect(parseCmdShim(npm, shim)).toBe(entry); + expect(parseCmdShim(pnpm, shim)).toBe(entry); + expect(parseCmdShim('@echo off\r\nnative.exe %*', shim)).toBeUndefined(); + }); + + it('escapes arguments for the cmd.exe fallback', () => { + expect(escapeCmdArgument('--cwd=C:\\a b')).toBe('^^^"--cwd=C:\\a^^^ b^^^"'); + expect(escapeCmdArgument('x&y|z')).toBe('^^^"x^^^&y^^^|z^^^"'); + const command = buildSpawnCommand( + { kind: 'cmd', path: 'C:\\bin\\mcode.cmd', source: 'path' }, + ['exec'], + { + ComSpec: 'C:\\Windows\\cmd.exe', + }, + ); + expect(command).toEqual({ + command: 'C:\\Windows\\cmd.exe', + args: ['/d', '/s', '/c', '"C:\\bin\\mcode.cmd ^^^"exec^^^""'], + windowsVerbatimArguments: true, + }); + }); +}); + +describe('CLI version check', () => { + it('compares major.minor.patch and accepts preview builds of the minimum', () => { + const satisfies = (text: string): boolean => { + const version = parseVersion(text); + const minimum = parseVersion('0.7.0'); + if (!version || !minimum) throw new Error(`unparseable version ${text}`); + return satisfiesMinimum(version, minimum); + }; + expect(satisfies('0.7.0-preview.1015121')).toBe(true); + expect(satisfies('mcode 0.7.1')).toBe(true); + expect(satisfies('1.0.0')).toBe(true); + expect(satisfies('0.6.12')).toBe(false); + }); + + it('rejects an older CLI before running', async () => { + const error = await fakeClient({ FAKE_MCODE_VERSION: '0.6.12' }) + .startThread() + .run('x') + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(MCodeVersionError); + expect((error as MCodeVersionError).version).toBe('0.6.12'); + expect((error as Error).message).toContain('npm install @minimax-ai/code@latest'); + }); + + it('checks the version once per client', async () => { + const log = join(tempDir(), 'versions.log'); + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('resumed'), + FAKE_MCODE_VERSION_LOG: log, + }); + await mcode.startThread().run('a'); + await mcode.startThread().run('b'); + expect(readFileSync(log, 'utf8')).toBe('version\n'); + }); +}); + +describe('package metadata', () => { + it('keeps engines.node identical to the mcode CLI package', () => { + const read = (path: string): { engines: { node: string } } => + JSON.parse(readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')) as { + engines: { node: string }; + }; + expect(read('../package.json').engines.node).toBe(read('../../tui/package.json').engines.node); + }); +}); diff --git a/packages/code-sdk/test/exit-codes.test.ts b/packages/code-sdk/test/exit-codes.test.ts new file mode 100644 index 000000000..92f2a9f42 --- /dev/null +++ b/packages/code-sdk/test/exit-codes.test.ts @@ -0,0 +1,168 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + MCodeAbortError, + MCodeInvocationError, + MCodeProtocolError, + MCodeRunError, +} from '../src/index.js'; +import { processTimings } from '../src/exec-process.js'; +import { fakeClient, fixture, tempDir } from './helpers.js'; + +const posixOnly = process.platform === 'win32' ? it.skip : it; + +async function failureFor( + exitCode: number, + stderr = 'mcode exec failed: details', +): Promise { + const mcode = fakeClient({ FAKE_MCODE_EXIT: String(exitCode), FAKE_MCODE_STDERR: stderr }); + return mcode + .startThread() + .run('x') + .catch((error: unknown) => error); +} + +describe('exit code mapping without exec.completed', () => { + it('maps exit 2 to MCodeInvocationError with the CLI message', async () => { + const error = await failureFor(2, 'error: --effort must be one of low, medium, high'); + expect(error).toBeInstanceOf(MCodeInvocationError); + expect((error as MCodeInvocationError).message).toContain('--effort must be one of'); + expect((error as MCodeInvocationError).exitCode).toBe(2); + }); + + it.each([ + [3, 'failed', 'config'], + [4, 'failed', 'runtime'], + [6, 'timeout', undefined], + [7, 'limit_exceeded', undefined], + [70, 'failed', 'internal'], + ] as const)('maps exit %i to MCodeRunError(status=%s)', async (code, status, category) => { + const error = await failureFor(code); + expect(error).toBeInstanceOf(MCodeRunError); + expect((error as MCodeRunError).status).toBe(status); + expect((error as MCodeRunError).category).toBe(category); + expect((error as MCodeRunError).result).toBeUndefined(); + expect((error as MCodeRunError).stderr).toContain('details'); + }); + + it('maps exit 130 to MCodeAbortError', async () => { + const error = await failureFor(130); + expect(error).toBeInstanceOf(MCodeAbortError); + expect((error as Error).name).toBe('AbortError'); + }); + + it.each([141, 0])('maps exit %i to MCodeProtocolError', async (code) => { + expect(await failureFor(code)).toBeInstanceOf(MCodeProtocolError); + }); +}); + +describe('failures before exec.completed keep the observed Session', () => { + it('reports the sessionId from exec.started when the CLI exits 4 without a result', async () => { + const path = `${tempDir()}/started-only.jsonl`; + const lines = readFileSync(fixture('failed-upstream'), 'utf8').split('\n').slice(0, 3); + writeFileSync(path, `${lines.join('\n')}\n`); + + const error = await fakeClient({ FAKE_MCODE_FIXTURE: path, FAKE_MCODE_EXIT: '4' }) + .startThread() + .run('x') + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MCodeRunError); + expect((error as MCodeRunError).result).toBeUndefined(); + expect((error as MCodeRunError).sessionId).toBe('mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a'); + }); + + it('falls back to the resumed Session id when the CLI emitted no event', async () => { + const error = await fakeClient({ FAKE_MCODE_EXIT: '3' }) + .resumeThread('mvs_resumed') + .run('x') + .catch((caught: unknown) => caught); + expect((error as MCodeRunError).sessionId).toBe('mvs_resumed'); + }); +}); + +describe('process exit with inherited pipes', () => { + const grandchildren: number[] = []; + afterEach(() => { + for (const pid of grandchildren.splice(0)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone. + } + } + }); + + it('settles shortly after the CLI exits even if a grandchild keeps stdout open', async () => { + const pidFile = `${tempDir()}/grandchild.pid`; + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('resumed'), + FAKE_MCODE_GRANDCHILD_PID: pidFile, + }); + const started = Date.now(); + + const turn = await mcode.startThread().run('go', { timeoutMs: 1_000 }); + + if (existsSync(pidFile)) grandchildren.push(Number(readFileSync(pidFile, 'utf8'))); + // The grandchild lives for 30s; the SDK drains for at most processTimings.exitDrainMs (2s). + expect(Date.now() - started).toBeLessThan(processTimings.exitDrainMs + 3_000); + // All lines written before exit are still delivered, including exec.completed. + expect(turn.finalResponse).toMatch(/^I wrote exactly/u); + expect(grandchildren).toHaveLength(1); + }); +}); + +describe('cancellation escalation and timeout backstop', () => { + posixOnly('escalates SIGINT to SIGTERM and SIGKILL when the CLI ignores signals', async () => { + const original = { ...processTimings }; + Object.assign(processTimings, { interruptGraceMs: 50, terminateGraceMs: 50 }); + try { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'ignore-signals', + FAKE_MCODE_SPLIT: '5', + }); + const thread = mcode.startThread(); + const controller = new AbortController(); + const pending = thread + .run('x', { signal: controller.signal }) + .catch((error: unknown) => error); + while (thread.id === null) await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + + const error = await pending; + expect(error).toBeInstanceOf(MCodeAbortError); + expect((error as MCodeAbortError).signal).toBe('SIGKILL'); + expect((error as MCodeAbortError).sessionId).toBe('mvs_78b922a823a345728486967e33c95770'); + // Items seen before the kill are kept for diagnosis even without a result. + expect((error as MCodeAbortError).items).toEqual([ + expect.objectContaining({ type: 'agent_message', incomplete: true }), + ]); + } finally { + Object.assign(processTimings, original); + } + }); + + posixOnly('force-stops a CLI that outlives timeoutMs and reports status timeout', async () => { + const original = { ...processTimings }; + Object.assign(processTimings, { backstopExtraMs: 50 }); + try { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'hang', + }); + const error = await mcode + .startThread() + .run('x', { timeoutMs: 100 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MCodeRunError); + expect((error as MCodeRunError).status).toBe('timeout'); + expect((error as MCodeRunError).signal).toBe('SIGKILL'); + } finally { + Object.assign(processTimings, original); + } + }); +}); diff --git a/packages/code-sdk/test/fixtures/cancelled.jsonl b/packages/code-sdk/test/fixtures/cancelled.jsonl new file mode 100644 index 000000000..af28bb6be --- /dev/null +++ b/packages/code-sdk/test/fixtures/cancelled.jsonl @@ -0,0 +1,11 @@ +{"schemaVersion":1,"sequence":1,"timestampMs":1793155269971,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"exec.started"} +{"schemaVersion":1,"sequence":2,"timestampMs":1793155269972,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"session.started"} +{"schemaVersion":1,"sequence":3,"timestampMs":1793155269972,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"turn.started"} +{"schemaVersion":1,"sequence":4,"timestampMs":1793155278599,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"item.started","item":{"id":"fe35001b-8bf2-4327-9c72-91116279c9c0:message","type":"agent_message","contentDelta":"I'll run that"}} +{"schemaVersion":1,"sequence":5,"timestampMs":1793155278607,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"item.updated","item":{"id":"fe35001b-8bf2-4327-9c72-91116279c9c0:message","type":"agent_message","contentDelta":" command now."}} +{"schemaVersion":1,"sequence":6,"timestampMs":1793155278608,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"item.started","item":{"id":"call_function_h07pns6jttzu_1","type":"tool_call","toolCall":{"id":"call_function_h07pns6jttzu_1","name":"bash","status":4}}} +{"schemaVersion":1,"sequence":7,"timestampMs":1793155278608,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"item.updated","item":{"id":"call_function_h07pns6jttzu_1","type":"tool_call","toolCall":{"id":"call_function_h07pns6jttzu_1","name":"bash","status":4}}} +{"schemaVersion":1,"sequence":8,"timestampMs":1793155278608,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"item.updated","item":{"id":"call_function_h07pns6jttzu_1","type":"tool_call","toolCall":{"id":"call_function_h07pns6jttzu_1","name":"bash","status":5}}} +{"schemaVersion":1,"sequence":9,"timestampMs":1793155278626,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"item.updated","item":{"id":"call_function_h07pns6jttzu_1","type":"tool_call","toolCall":{"id":"call_function_h07pns6jttzu_1","name":"bash","status":1,"input":{"command":"sleep 60 && echo done","description":"Sleep 60 seconds then echo done","timeout":90}}}} +{"schemaVersion":1,"sequence":10,"timestampMs":1793155280845,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"turn.failed","status":"cancelled","durationMs":9686} +{"schemaVersion":1,"sequence":11,"timestampMs":1793155280845,"runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","type":"exec.completed","result":{"schemaVersion":1,"type":"exec.result","runId":"exec_turn_mvricldv_at3q6m","sessionId":"mvs_78b922a823a345728486967e33c95770","turnId":"turn_mvricldv_at3q6m","status":"cancelled","model":{"providerId":"minimax","modelId":"MiniMax-M3","variant":"thinking","providerSource":"provider","providerKind":"minimax-managed"},"usage":{"inputTokens":669,"outputTokens":61,"reasoningTokens":0,"cacheReadTokens":25152,"cacheWriteTokens":0,"totalTokens":730},"usageSource":"analytics_fallback","usageIncomplete":true,"durationMs":9686}} diff --git a/packages/code-sdk/test/fixtures/cli-package/mcode.mjs b/packages/code-sdk/test/fixtures/cli-package/mcode.mjs new file mode 100755 index 000000000..41aeb9ebe --- /dev/null +++ b/packages/code-sdk/test/fixtures/cli-package/mcode.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Test double for `mcode`. Replays recorded `mcode exec --output-format stream-json` output. +// +// Behaviour is driven by environment variables so tests can pass them through MCode({ env }): +// FAKE_MCODE_VERSION output of `--version` (default 0.7.0) +// FAKE_MCODE_VERSION_LOG file that gets one line appended per `--version` call +// FAKE_MCODE_FIXTURE JSONL file to replay on stdout +// FAKE_MCODE_EXIT exit code after replay (default 0) +// FAKE_MCODE_STDERR text written to stderr before exiting +// FAKE_MCODE_RECORD file that receives { argv, stdin, env, promptFiles } as JSON +// FAKE_MCODE_MODE 'normal' | 'wait-sigint' | 'ignore-signals' | 'hang' +// FAKE_MCODE_SPLIT non-normal modes: lines replayed before waiting (default 3) +// FAKE_MCODE_PREFIX raw text written to stdout before the fixture +// FAKE_MCODE_GRANDCHILD_PID normal mode: before exiting, spawn a long-lived grandchild that +// inherits stdout / stderr and write its pid to this file +import { spawn } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; + +const env = process.env; +const argv = process.argv.slice(2); + +if (argv[0] === '--version') { + if (env.FAKE_MCODE_VERSION_LOG) appendFileSync(env.FAKE_MCODE_VERSION_LOG, 'version\n'); + process.stdout.write(`${env.FAKE_MCODE_VERSION ?? '0.7.0'}\n`); + process.exit(0); +} + +const stdinChunks = []; +process.stdin.on('data', (chunk) => stdinChunks.push(chunk)); +process.stdin.on('end', () => { + const stdin = Buffer.concat(stdinChunks).toString('utf8'); + if (env.FAKE_MCODE_RECORD) { + const promptFiles = {}; + for (const arg of argv) { + const match = /^--((?:append-)?system-prompt-file)=(.*)$/u.exec(arg); + if (match) promptFiles[match[1]] = readFileSync(match[2], 'utf8'); + } + const recordedEnv = { MINIMAX_DATA_DIR: env.MINIMAX_DATA_DIR ?? null }; + writeFileSync( + env.FAKE_MCODE_RECORD, + JSON.stringify({ argv, stdin, env: recordedEnv, promptFiles }), + ); + } + run(); +}); + +function run() { + const lines = env.FAKE_MCODE_FIXTURE + ? readFileSync(env.FAKE_MCODE_FIXTURE, 'utf8').split('\n').filter(Boolean) + : []; + const exitCode = Number(env.FAKE_MCODE_EXIT ?? '0'); + const mode = env.FAKE_MCODE_MODE ?? 'normal'; + if (env.FAKE_MCODE_PREFIX) process.stdout.write(env.FAKE_MCODE_PREFIX); + + const finish = (rest, code) => { + for (const line of rest) process.stdout.write(`${line}\n`); + if (env.FAKE_MCODE_STDERR) process.stderr.write(`${env.FAKE_MCODE_STDERR}\n`); + process.stdout.end(() => process.exit(code)); + }; + + if (mode === 'normal') { + if (env.FAKE_MCODE_GRANDCHILD_PID) { + const grandchild = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + writeFileSync(env.FAKE_MCODE_GRANDCHILD_PID, String(grandchild.pid)); + grandchild.unref(); + } + finish(lines, exitCode); + return; + } + const split = Number(env.FAKE_MCODE_SPLIT ?? '3'); + const keepAlive = setInterval(() => {}, 1_000); + // Install handlers before replaying, so a consumer that saw an event can signal safely. + if (mode === 'wait-sigint') { + process.once('SIGINT', () => { + clearInterval(keepAlive); + finish(lines.slice(split), 130); + }); + } else if (mode === 'ignore-signals') { + process.on('SIGINT', () => {}); + process.on('SIGTERM', () => {}); + } + // 'hang' keeps default signal handling and never finishes on its own. + for (const line of lines.slice(0, split)) process.stdout.write(`${line}\n`); +} diff --git a/packages/code-sdk/test/fixtures/cli-package/package.json b/packages/code-sdk/test/fixtures/cli-package/package.json new file mode 100644 index 000000000..993e73cd9 --- /dev/null +++ b/packages/code-sdk/test/fixtures/cli-package/package.json @@ -0,0 +1,13 @@ +{ + "name": "@minimax-ai/code", + "version": "0.7.0", + "private": true, + "description": "Test double for the optional @minimax-ai/code peer dependency; never published.", + "type": "module", + "bin": { + "mcode": "mcode.mjs" + }, + "exports": { + "./package.json": "./package.json" + } +} diff --git a/packages/code-sdk/test/fixtures/failed-upstream.jsonl b/packages/code-sdk/test/fixtures/failed-upstream.jsonl new file mode 100644 index 000000000..6c3ba4878 --- /dev/null +++ b/packages/code-sdk/test/fixtures/failed-upstream.jsonl @@ -0,0 +1,5 @@ +{"schemaVersion":1,"sequence":1,"timestampMs":1793155067573,"runId":"exec_turn_mvri897m_6cfkqw","sessionId":"mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a","turnId":"turn_mvri897m_6cfkqw","type":"exec.started"} +{"schemaVersion":1,"sequence":2,"timestampMs":1793155067573,"runId":"exec_turn_mvri897m_6cfkqw","sessionId":"mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a","turnId":"turn_mvri897m_6cfkqw","type":"session.started"} +{"schemaVersion":1,"sequence":3,"timestampMs":1793155067573,"runId":"exec_turn_mvri897m_6cfkqw","sessionId":"mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a","turnId":"turn_mvri897m_6cfkqw","type":"turn.started"} +{"schemaVersion":1,"sequence":4,"timestampMs":1793155100985,"runId":"exec_turn_mvri897m_6cfkqw","sessionId":"mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a","turnId":"turn_mvri897m_6cfkqw","type":"turn.failed","status":"failed","error":{"category":"runtime","message":"BYOK provider custom_provider:provider-redacted upstream error: 429 Your account org-REDACTED is suspended due to insufficient balance, please recharge your account or check your plan and billing details","retryable":true},"durationMs":32279} +{"schemaVersion":1,"sequence":5,"timestampMs":1793155100985,"runId":"exec_turn_mvri897m_6cfkqw","sessionId":"mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a","turnId":"turn_mvri897m_6cfkqw","type":"exec.completed","result":{"schemaVersion":1,"type":"exec.result","runId":"exec_turn_mvri897m_6cfkqw","sessionId":"mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a","turnId":"turn_mvri897m_6cfkqw","status":"failed","error":{"category":"runtime","message":"BYOK provider custom_provider:provider-redacted upstream error: 429 Your account org-REDACTED is suspended due to insufficient balance, please recharge your account or check your plan and billing details","retryable":true},"model":{"providerId":"custom_provider:provider-redacted","modelId":"kimi-k3.1-pro","variant":"thinking","providerSource":"custom_provider","providerKind":"custom","protocol":"openai-completions"},"usageSource":"unavailable","usageIncomplete":true,"durationMs":32279}} diff --git a/packages/code-sdk/test/fixtures/resumed.jsonl b/packages/code-sdk/test/fixtures/resumed.jsonl new file mode 100644 index 000000000..b7455c82e --- /dev/null +++ b/packages/code-sdk/test/fixtures/resumed.jsonl @@ -0,0 +1,9 @@ +{"schemaVersion":1,"sequence":1,"timestampMs":1793155201106,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"exec.started"} +{"schemaVersion":1,"sequence":2,"timestampMs":1793155201106,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"session.resumed"} +{"schemaVersion":1,"sequence":3,"timestampMs":1793155201106,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"turn.started"} +{"schemaVersion":1,"sequence":4,"timestampMs":1793155210081,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"item.started","item":{"id":"99d17342-801e-4539-8bce-bcb6f94fe7fe:message","type":"agent_message","contentDelta":"I wrote exactly"}} +{"schemaVersion":1,"sequence":5,"timestampMs":1793155210085,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"item.updated","item":{"id":"99d17342-801e-4539-8bce-bcb6f94fe7fe:message","type":"agent_message","contentDelta":":\n\n```\nHELLO SDK\n```\n\nThat is, the literal content `HELLO SDK` followed by a trailing newline (10 bytes total) — `note.txt`'s content"}} +{"schemaVersion":1,"sequence":6,"timestampMs":1793155210224,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"item.updated","item":{"id":"99d17342-801e-4539-8bce-bcb6f94fe7fe:message","type":"agent_message","contentDelta":" `hello sdk` uppercased."}} +{"schemaVersion":1,"sequence":7,"timestampMs":1793155210369,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"item.completed","item":{"id":"99d17342-801e-4539-8bce-bcb6f94fe7fe:message","type":"agent_message","content":"I wrote exactly:\n\n```\nHELLO SDK\n```\n\nThat is, the literal content `HELLO SDK` followed by a trailing newline (10 bytes total) — `note.txt`'s content `hello sdk` uppercased."}} +{"schemaVersion":1,"sequence":8,"timestampMs":1793155211959,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"turn.completed","model":{"providerId":"minimax","modelId":"MiniMax-M3","variant":"thinking","providerSource":"provider","providerKind":"minimax-managed"},"usage":{"inputTokens":832,"outputTokens":47,"cacheReadTokens":26149,"totalTokens":879},"usageSource":"completed_responses","usageIncomplete":false,"durationMs":9325} +{"schemaVersion":1,"sequence":9,"timestampMs":1793155211959,"runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","type":"exec.completed","result":{"schemaVersion":1,"type":"exec.result","runId":"exec_turn_mvrib48y_693m74","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvrib48y_693m74","status":"succeeded","output":"I wrote exactly:\n\n```\nHELLO SDK\n```\n\nThat is, the literal content `HELLO SDK` followed by a trailing newline (10 bytes total) — `note.txt`'s content `hello sdk` uppercased.","model":{"providerId":"minimax","modelId":"MiniMax-M3","variant":"thinking","providerSource":"provider","providerKind":"minimax-managed"},"usage":{"inputTokens":832,"outputTokens":47,"cacheReadTokens":26149,"totalTokens":879},"usageSource":"completed_responses","usageIncomplete":false,"durationMs":9325}} diff --git a/packages/code-sdk/test/fixtures/success-tools.jsonl b/packages/code-sdk/test/fixtures/success-tools.jsonl new file mode 100644 index 000000000..59a59ff07 --- /dev/null +++ b/packages/code-sdk/test/fixtures/success-tools.jsonl @@ -0,0 +1,31 @@ +{"schemaVersion":1,"sequence":1,"timestampMs":1793155145143,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"exec.started"} +{"schemaVersion":1,"sequence":2,"timestampMs":1793155145143,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"session.started"} +{"schemaVersion":1,"sequence":3,"timestampMs":1793155145143,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"turn.started"} +{"schemaVersion":1,"sequence":4,"timestampMs":1793155154127,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.started","item":{"id":"49aa967a-62c0-46ca-9577-5934d3f2a572:message","type":"agent_message","contentDelta":"I'll check"}} +{"schemaVersion":1,"sequence":5,"timestampMs":1793155154134,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"49aa967a-62c0-46ca-9577-5934d3f2a572:message","type":"agent_message","contentDelta":" the workspace and note file first."}} +{"schemaVersion":1,"sequence":6,"timestampMs":1793155154134,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.started","item":{"id":"call_function_0429v80y2s4q_1","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_1","name":"read","status":4}}} +{"schemaVersion":1,"sequence":7,"timestampMs":1793155154134,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_1","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_1","name":"read","status":4}}} +{"schemaVersion":1,"sequence":8,"timestampMs":1793155154134,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_1","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_1","name":"read","status":5}}} +{"schemaVersion":1,"sequence":9,"timestampMs":1793155154134,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.started","item":{"id":"call_function_0429v80y2s4q_2","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_2","name":"bash","status":4}}} +{"schemaVersion":1,"sequence":10,"timestampMs":1793155154135,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_2","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_2","name":"bash","status":4}}} +{"schemaVersion":1,"sequence":11,"timestampMs":1793155154135,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_2","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_2","name":"bash","status":5}}} +{"schemaVersion":1,"sequence":12,"timestampMs":1793155154153,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_1","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_1","name":"read","status":1,"input":{"path":"note.txt"}}}} +{"schemaVersion":1,"sequence":13,"timestampMs":1793155154283,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_1","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_1","name":"read","status":2,"input":{"path":"note.txt"},"output":{"content":[{"type":"text","text":" 1→hello sdk\n"}],"details":{"textFile":{"path":"/workspace/project/note.txt","content":"hello sdk\n","numLines":1,"startLine":1,"totalLines":2,"truncatedByOutputLimit":false,"wholeFileAutoTruncated":false}}},"durationMs":130}}} +{"schemaVersion":1,"sequence":14,"timestampMs":1793155154289,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_2","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_2","name":"bash","status":1,"input":{"command":"ls -la","description":"List workspace files"}}}} +{"schemaVersion":1,"sequence":15,"timestampMs":1793155154352,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_0429v80y2s4q_2","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_2","name":"bash","status":2,"input":{"command":"ls -la","description":"List workspace files"},"output":{"content":[{"type":"text","text":"total 8\ndrwxr-xr-x@ 4 user staff 128 Oct 28 10:37 .\ndrwxr-xr-x@ 7 user staff 224 Oct 28 10:39 ..\ndrwxr-xr-x@ 9 user staff 288 Oct 28 10:37 .git\n-rw-r--r--@ 1 user staff 10 Oct 28 10:37 note.txt\n"}],"details":{"execution":{"status":"succeeded","reason":"exited","exitCode":0},"timing":{"commandTimeoutSeconds":3600,"commandTimerStartedAt":1793155154340,"commandDeadlineAt":1793158754340},"output":{"rawBytes":215,"persistence":"complete","omittedBytes":0},"processOutput":{"stdout":"total 8\ndrwxr-xr-x@ 4 user staff 128 Oct 28 10:37 .\ndrwxr-xr-x@ 7 user staff 224 Oct 28 10:39 ..\ndrwxr-xr-x@ 9 user staff 288 Oct 28 10:37 .git\n-rw-r--r--@ 1 user staff 10 Oct 28 10:37 note.txt\n","stderr":"","exitCode":0,"interrupted":false,"stdoutTruncated":false,"stderrTruncated":false,"rawOutputPath":"/home/user/.minimax/background-tasks/bg_761a98e8-8a41-4536-be64-bf99586fc5d9/output.log"},"description":"List workspace files","fullOutputPath":"/home/user/.minimax/background-tasks/bg_761a98e8-8a41-4536-be64-bf99586fc5d9/output.log","envSanitized":["MAVIS_BUILD_ENV","MAVIS_DATA_DIR","MAVIS_REGION","MINIMAX_DATA_DIR","__MAVIS_RUNTIME_MANAGED"],"status":"succeeded","task_id":"bg_761a98e8-8a41-4536-be64-bf99586fc5d9"}},"durationMs":62}}} +{"schemaVersion":1,"sequence":16,"timestampMs":1793155154490,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.completed","item":{"id":"49aa967a-62c0-46ca-9577-5934d3f2a572:message","type":"agent_message","content":"I'll check the workspace and note file first."}} +{"schemaVersion":1,"sequence":17,"timestampMs":1793155154490,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.completed","item":{"id":"call_function_0429v80y2s4q_1","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_1","name":"read","status":2,"input":{"path":"note.txt"},"output":{"content":[{"type":"text","text":" 1→hello sdk\n"}],"details":{"textFile":{"path":"/workspace/project/note.txt","content":"hello sdk\n","numLines":1,"startLine":1,"totalLines":2,"truncatedByOutputLimit":false,"wholeFileAutoTruncated":false}}},"durationMs":130}}} +{"schemaVersion":1,"sequence":18,"timestampMs":1793155154490,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.completed","item":{"id":"call_function_0429v80y2s4q_2","type":"tool_call","toolCall":{"id":"call_function_0429v80y2s4q_2","name":"bash","status":2,"input":{"command":"ls -la","description":"List workspace files"},"output":{"content":[{"type":"text","text":"total 8\ndrwxr-xr-x@ 4 user staff 128 Oct 28 10:37 .\ndrwxr-xr-x@ 7 user staff 224 Oct 28 10:39 ..\ndrwxr-xr-x@ 9 user staff 288 Oct 28 10:37 .git\n-rw-r--r--@ 1 user staff 10 Oct 28 10:37 note.txt\n"}],"details":{"execution":{"status":"succeeded","reason":"exited","exitCode":0},"timing":{"commandTimeoutSeconds":3600,"commandTimerStartedAt":1793155154340,"commandDeadlineAt":1793158754340},"output":{"rawBytes":215,"persistence":"complete","omittedBytes":0},"processOutput":{"stdout":"total 8\ndrwxr-xr-x@ 4 user staff 128 Oct 28 10:37 .\ndrwxr-xr-x@ 7 user staff 224 Oct 28 10:39 ..\ndrwxr-xr-x@ 9 user staff 288 Oct 28 10:37 .git\n-rw-r--r--@ 1 user staff 10 Oct 28 10:37 note.txt\n","stderr":"","exitCode":0,"interrupted":false,"stdoutTruncated":false,"stderrTruncated":false,"rawOutputPath":"/home/user/.minimax/background-tasks/bg_761a98e8-8a41-4536-be64-bf99586fc5d9/output.log"},"description":"List workspace files","fullOutputPath":"/home/user/.minimax/background-tasks/bg_761a98e8-8a41-4536-be64-bf99586fc5d9/output.log","envSanitized":["MAVIS_BUILD_ENV","MAVIS_DATA_DIR","MAVIS_REGION","MINIMAX_DATA_DIR","__MAVIS_RUNTIME_MANAGED"],"status":"succeeded","task_id":"bg_761a98e8-8a41-4536-be64-bf99586fc5d9"}},"durationMs":62}}} +{"schemaVersion":1,"sequence":19,"timestampMs":1793155155383,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.started","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":4}}} +{"schemaVersion":1,"sequence":20,"timestampMs":1793155155385,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":4}}} +{"schemaVersion":1,"sequence":21,"timestampMs":1793155155499,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":4}}} +{"schemaVersion":1,"sequence":22,"timestampMs":1793155155510,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":5}}} +{"schemaVersion":1,"sequence":23,"timestampMs":1793155155529,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":1,"input":{"path":"out.txt","content":"HELLO SDK\n"},"structuredPreview":{"schemaVersion":1,"state":"proposed","blocks":[{"kind":"file","path":"out.txt","content":"HELLO SDK\n","lineCount":2,"truncated":false}]}}}} +{"schemaVersion":1,"sequence":24,"timestampMs":1793155155559,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":2,"input":{"path":"out.txt","content":"HELLO SDK\n"},"output":{"content":[{"type":"text","text":"Successfully wrote 10 bytes to out.txt"}],"details":{"created":true,"bytes_written":10,"encoding":"utf-8","bom":false}},"durationMs":30,"structuredPreview":{"schemaVersion":1,"state":"applied","blocks":[{"kind":"file","path":"out.txt","content":"HELLO SDK\n","lineCount":2,"truncated":false}]}}}} +{"schemaVersion":1,"sequence":25,"timestampMs":1793155155560,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.completed","item":{"id":"call_function_wkdubj76wqb4_1","type":"tool_call","toolCall":{"id":"call_function_wkdubj76wqb4_1","name":"write","status":2,"input":{"path":"out.txt","content":"HELLO SDK\n"},"output":{"content":[{"type":"text","text":"Successfully wrote 10 bytes to out.txt"}],"details":{"created":true,"bytes_written":10,"encoding":"utf-8","bom":false}},"durationMs":30,"structuredPreview":{"schemaVersion":1,"state":"applied","blocks":[{"kind":"file","path":"out.txt","content":"HELLO SDK\n","lineCount":2,"truncated":false}]}}}} +{"schemaVersion":1,"sequence":26,"timestampMs":1793155156834,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.started","item":{"id":"d645b561-b951-4ccb-b2bb-767c2f75925b:message","type":"agent_message","contentDelta":"Created"}} +{"schemaVersion":1,"sequence":27,"timestampMs":1793155156835,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"d645b561-b951-4ccb-b2bb-767c2f75925b:message","type":"agent_message","contentDelta":" `out.txt` with the uppercase content \"HELLO SDK\" from `note.txt` (workspace contains only `note.txt` and `.git`).\n\n[out.txt](/workspace/project/out"}} +{"schemaVersion":1,"sequence":28,"timestampMs":1793155156958,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.updated","item":{"id":"d645b561-b951-4ccb-b2bb-767c2f75925b:message","type":"agent_message","contentDelta":".txt)"}} +{"schemaVersion":1,"sequence":29,"timestampMs":1793155157090,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"item.completed","item":{"id":"d645b561-b951-4ccb-b2bb-767c2f75925b:message","type":"agent_message","content":"Created `out.txt` with the uppercase content \"HELLO SDK\" from `note.txt` (workspace contains only `note.txt` and `.git`).\n\n[out.txt](/workspace/project/out.txt)"}} +{"schemaVersion":1,"sequence":30,"timestampMs":1793155158075,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"turn.completed","model":{"providerId":"minimax","modelId":"MiniMax-M3","variant":"thinking","providerSource":"provider","providerKind":"minimax-managed"},"usage":{"inputTokens":25815,"outputTokens":157,"cacheReadTokens":52166,"totalTokens":25972},"usageSource":"completed_responses","usageIncomplete":false,"durationMs":12001} +{"schemaVersion":1,"sequence":31,"timestampMs":1793155158075,"runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","type":"exec.completed","result":{"schemaVersion":1,"type":"exec.result","runId":"exec_turn_mvri9x2c_ffy2ij","sessionId":"mvs_2bab54bd1f17409e968db62f8ed2784a","turnId":"turn_mvri9x2c_ffy2ij","status":"succeeded","output":"Created `out.txt` with the uppercase content \"HELLO SDK\" from `note.txt` (workspace contains only `note.txt` and `.git`).\n\n[out.txt](/workspace/project/out.txt)","model":{"providerId":"minimax","modelId":"MiniMax-M3","variant":"thinking","providerSource":"provider","providerKind":"minimax-managed"},"usage":{"inputTokens":25815,"outputTokens":157,"cacheReadTokens":52166,"totalTokens":25972},"usageSource":"completed_responses","usageIncomplete":false,"durationMs":12001}} diff --git a/packages/code-sdk/test/helpers.ts b/packages/code-sdk/test/helpers.ts new file mode 100644 index 000000000..6efdd975e --- /dev/null +++ b/packages/code-sdk/test/helpers.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach } from 'vitest'; + +import { MCode } from '../src/index.js'; +import type { MCodeOptions } from '../src/index.js'; + +const fixtures = fileURLToPath(new URL('./fixtures/', import.meta.url)); + +export const FAKE_MCODE = join(fixtures, 'cli-package', 'mcode.mjs'); + +export type Fixture = 'success-tools' | 'failed-upstream' | 'resumed' | 'cancelled'; + +export function fixture(name: Fixture): string { + return join(fixtures, `${name}.jsonl`); +} + +export function fixtureEvents(name: Fixture): Record[] { + return readFileSync(fixture(name), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +export function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'code-sdk-test-')); + tempDirs.push(dir); + return dir; +} + +export interface FakeEnv { + readonly FAKE_MCODE_FIXTURE?: string; + readonly FAKE_MCODE_EXIT?: string; + readonly FAKE_MCODE_STDERR?: string; + readonly FAKE_MCODE_MODE?: 'normal' | 'wait-sigint' | 'ignore-signals' | 'hang'; + readonly FAKE_MCODE_SPLIT?: string; + readonly FAKE_MCODE_PREFIX?: string; + readonly FAKE_MCODE_VERSION?: string; + readonly FAKE_MCODE_VERSION_LOG?: string; + readonly FAKE_MCODE_RECORD?: string; + readonly FAKE_MCODE_GRANDCHILD_PID?: string; +} + +export function fakeClient(fake: FakeEnv, options: Omit = {}): MCode { + return new MCode({ + bin: FAKE_MCODE, + ...options, + env: { PATH: process.env.PATH, ...fake }, + }); +} + +export interface Recorded { + readonly argv: string[]; + readonly stdin: string; + readonly env: { MINIMAX_DATA_DIR: string | null }; + readonly promptFiles: Record; +} + +export function readRecord(path: string): Recorded { + return JSON.parse(readFileSync(path, 'utf8')) as Recorded; +} diff --git a/packages/code-sdk/test/project-bin.test.ts b/packages/code-sdk/test/project-bin.test.ts new file mode 100644 index 000000000..07446eff6 --- /dev/null +++ b/packages/code-sdk/test/project-bin.test.ts @@ -0,0 +1,117 @@ +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + mkdirSync, + realpathSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { buildSpawnCommand, resolveCli } from '../src/cli-resolver.js'; +import { findProjectBin, parseCmdShim, parseShellShim } from '../src/project-bin.js'; +import { FAKE_MCODE, tempDir } from './helpers.js'; + +const posixOnly = process.platform === 'win32' ? it.skip : it; + +/** A project whose CLI package (any name) is installed with a JS entry under node_modules. */ +function project(): { root: string; entry: string; bin: string } { + const root = tempDir(); + const packageDir = join(root, 'node_modules', '@scope', 'any-mcode-cli', 'dist'); + mkdirSync(packageDir, { recursive: true }); + const entry = join(packageDir, 'index.js'); + copyFileSync(FAKE_MCODE, entry); + writeFileSync( + join(root, 'node_modules', '@scope', 'any-mcode-cli', 'package.json'), + '{"type":"module"}', + ); + const bin = join(root, 'node_modules', '.bin'); + mkdirSync(bin, { recursive: true }); + return { root, entry, bin }; +} + +function versionOf(command: ReturnType): string { + return spawnSync(command.command, command.args, { encoding: 'utf8' }).stdout.trim(); +} + +describe('project-local node_modules/.bin/mcode', () => { + posixOnly('follows an npm-style symlink from a nested working directory to the JS entry', () => { + const { root, entry, bin } = project(); + symlinkSync(join('..', '@scope', 'any-mcode-cli', 'dist', 'index.js'), join(bin, 'mcode')); + const nested = join(root, 'packages', 'app', 'src'); + mkdirSync(nested, { recursive: true }); + + const cli = resolveCli({ env: { PATH: '' }, packageSearchFrom: [], projectSearchFrom: nested }); + + // Symlinks resolve to the real path (macOS temp dirs live under /private). + expect(cli).toEqual({ kind: 'node', path: realpathSync(entry), source: 'project' }); + expect(versionOf(buildSpawnCommand(cli, ['--version']))).toBe('0.7.0'); + }); + + posixOnly('resolves a pnpm-style shell shim to its JS entry', () => { + const { root, entry, bin } = project(); + const shim = join(bin, 'mcode'); + writeFileSync( + shim, + [ + '#!/bin/sh', + 'basedir=$(dirname "$(echo "$0" | sed -e \'s,\\\\,/,g\')")', + 'if [ -x "$basedir/node" ]; then', + ' exec "$basedir/node" "$basedir/../@scope/any-mcode-cli/dist/index.js" "$@"', + 'else', + ' exec node "$basedir/../@scope/any-mcode-cli/dist/index.js" "$@"', + 'fi', + ].join('\n'), + ); + chmodSync(shim, 0o755); + + expect(findProjectBin(root)).toEqual({ kind: 'node', path: entry, source: 'project' }); + }); + + it('takes precedence over PATH but not over an installed @minimax-ai/code package', () => { + const { root, bin } = project(); + if (process.platform === 'win32') { + writeFileSync( + join(bin, 'mcode.cmd'), + '"%~dp0\\..\\@scope\\any-mcode-cli\\dist\\index.js" %*', + ); + } else { + symlinkSync(join('..', '@scope', 'any-mcode-cli', 'dist', 'index.js'), join(bin, 'mcode')); + } + const onPath = tempDir(); + writeFileSync(join(onPath, 'mcode'), '#!/bin/sh\n'); + chmodSync(join(onPath, 'mcode'), 0o755); + + expect( + resolveCli({ env: { PATH: onPath }, packageSearchFrom: [], projectSearchFrom: root }).source, + ).toBe('project'); + const sdkSource = new URL('../src/cli-resolver.ts', import.meta.url).href; + expect( + resolveCli({ env: { PATH: onPath }, packageSearchFrom: [sdkSource], projectSearchFrom: root }) + .source, + ).toBe('package'); + }); + + it('returns nothing when no ancestor has node_modules/.bin/mcode', () => { + expect(findProjectBin(tempDir())).toBeUndefined(); + }); + + it('parses Windows cmd and PowerShell shims to the JS entry', () => { + const { entry, bin } = project(); + const cmd = join(bin, 'mcode.cmd'); + const ps1 = join(bin, 'mcode.ps1'); + expect( + parseCmdShim('"%_prog%" "%dp0%\\..\\@scope\\any-mcode-cli\\dist\\index.js" %*', cmd), + ).toBe(entry); + expect( + parseShellShim( + '& "$basedir/node$exe" "$basedir/../@scope/any-mcode-cli/dist/index.js" $args', + ps1, + ), + ).toBe(entry); + expect(parseShellShim('exec "$basedir/native-mcode" "$@"', ps1)).toBeUndefined(); + }); +}); diff --git a/packages/code-sdk/test/run.test.ts b/packages/code-sdk/test/run.test.ts new file mode 100644 index 000000000..f95dcc590 --- /dev/null +++ b/packages/code-sdk/test/run.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; + +import { MCodeAbortError, MCodeRunError, ThreadBusyError, type ThreadItem } from '../src/index.js'; +import { ExecProcess } from '../src/exec-process.js'; +import { FAKE_MCODE, fakeClient, fixture, readRecord, tempDir } from './helpers.js'; + +const SUCCESS_SESSION = 'mvs_2bab54bd1f17409e968db62f8ed2784a'; +const posixOnly = process.platform === 'win32' ? it.skip : it; + +async function waitFor(condition: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('condition not met in time'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('thread.run', () => { + it('resolves a successful Turn with items merged by id', async () => { + const mcode = fakeClient({ FAKE_MCODE_FIXTURE: fixture('success-tools') }); + const thread = mcode.startThread({ cwd: tempDir() }); + expect(thread.id).toBeNull(); + + const turn = await thread.run('Read note.txt and write its uppercase content to out.txt.'); + + expect(thread.id).toBe(SUCCESS_SESSION); + expect(turn.status).toBe('succeeded'); + expect(turn.sessionId).toBe(SUCCESS_SESSION); + expect(turn.turnId).toBe('turn_mvri9x2c_ffy2ij'); + expect(turn.finalResponse).toMatch( + /^Created `out.txt` with the uppercase content "HELLO SDK"/u, + ); + expect(turn.usage).toEqual({ + inputTokens: 25815, + outputTokens: 157, + cacheReadTokens: 52166, + totalTokens: 25972, + }); + expect(turn.model?.modelId).toBe('MiniMax-M3'); + expect(turn.result.status).toBe('succeeded'); + + // The first message completes after both tool calls started; order is first appearance. + expect(turn.items.map(summary)).toEqual([ + 'agent_message:49aa967a-62c0-46ca-9577-5934d3f2a572:message', + 'tool_call:call_function_0429v80y2s4q_1:read', + 'tool_call:call_function_0429v80y2s4q_2:bash', + 'tool_call:call_function_wkdubj76wqb4_1:write', + 'agent_message:d645b561-b951-4ccb-b2bb-767c2f75925b:message', + ]); + expect(turn.items.some((item) => item.incomplete)).toBe(false); + const [firstMessage, read] = turn.items; + expect(firstMessage).toMatchObject({ text: "I'll check the workspace and note file first." }); + expect(read).toMatchObject({ toolCall: { status: 2, input: { path: 'note.txt' } } }); + }); + + it('rejects with MCodeRunError carrying the full result when the run fails (exit 4)', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('failed-upstream'), + FAKE_MCODE_EXIT: '4', + FAKE_MCODE_STDERR: 'mcode exec failed: The run failed: upstream error: 429', + }); + const thread = mcode.startThread(); + + const error = await thread.run('hello').catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MCodeRunError); + const runError = error as MCodeRunError; + expect(runError.status).toBe('failed'); + expect(runError.category).toBe('runtime'); + expect(runError.retryable).toBe(true); + expect(runError.exitCode).toBe(4); + expect(runError.stderr).toContain('429'); + expect(runError.message).toContain('429'); + expect(runError.result?.status).toBe('failed'); + expect(runError.sessionId).toBe('mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a'); + // A failed first Turn still yields a Session the caller can resume. + expect(thread.id).toBe('mvs_8d8ee44bc4994c1cacd3af0aa64d7a0a'); + }); + + it('continues an existing Session with --session', async () => { + const record = `${tempDir()}/record.json`; + const mcode = fakeClient({ FAKE_MCODE_FIXTURE: fixture('resumed'), FAKE_MCODE_RECORD: record }); + const thread = mcode.resumeThread(SUCCESS_SESSION, { model: 'minimax/MiniMax-M3' }); + + const turn = await thread.run('What exactly did you write?'); + + expect(thread.id).toBe(SUCCESS_SESSION); + expect(turn.turnId).toBe('turn_mvrib48y_693m74'); + expect(turn.finalResponse).toMatch(/^I wrote exactly:/u); + const { argv } = readRecord(record); + expect(argv).toContain(`--session=${SUCCESS_SESSION}`); + expect(argv).toContain('--model=minimax/MiniMax-M3'); + }); + + it('continues its own Session on the next Turn and keeps the creation-only mode off it', async () => { + const record = `${tempDir()}/record.json`; + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('success-tools'), + FAKE_MCODE_RECORD: record, + }); + const thread = mcode.startThread({ mode: 'lightweight' }); + + await thread.run('first'); + const first = readRecord(record).argv; + expect(first).toContain('--mode=lightweight'); + expect(first.some((arg) => arg.startsWith('--session'))).toBe(false); + + await thread.run('second'); + const second = readRecord(record).argv; + expect(second).toContain(`--session=${SUCCESS_SESSION}`); + expect(second.some((arg) => arg.startsWith('--mode'))).toBe(false); + }); + + posixOnly('cancels with SIGINT and rejects with an AbortError', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'wait-sigint', + FAKE_MCODE_SPLIT: '9', + }); + const thread = mcode.startThread(); + const controller = new AbortController(); + + const pending = thread.run('sleep 60', { signal: controller.signal }); + await waitFor(() => thread.id !== null); + controller.abort(); + const error = await pending.catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MCodeAbortError); + const abort = error as MCodeAbortError; + expect(abort.name).toBe('AbortError'); + expect(abort.exitCode).toBe(130); + expect(abort.result?.status).toBe('cancelled'); + expect(abort.sessionId).toBe('mvs_78b922a823a345728486967e33c95770'); + // Neither the message nor the tool call received item.completed before cancellation. + expect(abort.items.map((item) => [item.type, item.incomplete])).toEqual([ + ['agent_message', true], + ['tool_call', true], + ]); + }); + + it('honors an abort that fires while prompt files are being prepared', async () => { + const record = `${tempDir()}/record.json`; + const mcode = fakeClient({ FAKE_MCODE_FIXTURE: fixture('resumed'), FAKE_MCODE_RECORD: record }); + await mcode.startThread().run('warm up the cached CLI check'); + + const controller = new AbortController(); + const pending = mcode + .startThread({ appendSystemPrompt: 'x' }) + .run('go', { signal: controller.signal }); + setImmediate(() => controller.abort()); + + await expect(pending).rejects.toBeInstanceOf(MCodeAbortError); + }); + + posixOnly('interrupts a process started with an already-aborted signal', async () => { + const controller = new AbortController(); + controller.abort(); + const exec = new ExecProcess({ + command: { command: process.execPath, args: [FAKE_MCODE, 'exec'] }, + env: { + PATH: process.env.PATH, + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'hang', + }, + prompt: 'x', + signal: controller.signal, + }); + + const consume = async (): Promise => { + for await (const event of exec.events()) void event; + }; + await expect(consume()).rejects.toBeInstanceOf(MCodeAbortError); + }); + + it('rejects immediately when the signal is already aborted', async () => { + const record = `${tempDir()}/record.json`; + const mcode = fakeClient({ FAKE_MCODE_RECORD: record }); + const controller = new AbortController(); + controller.abort(); + + await expect( + mcode.startThread().run('never', { signal: controller.signal }), + ).rejects.toBeInstanceOf(MCodeAbortError); + }); + + posixOnly('rejects a concurrent Turn on the same Thread with ThreadBusyError', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'wait-sigint', + FAKE_MCODE_SPLIT: '4', + }); + const thread = mcode.startThread(); + const controller = new AbortController(); + const first = thread.run('one', { signal: controller.signal }).catch((error: unknown) => error); + await waitFor(() => thread.id !== null); + + await expect(thread.run('two')).rejects.toBeInstanceOf(ThreadBusyError); + await expect(thread.runStreamed('three')).rejects.toBeInstanceOf(ThreadBusyError); + + controller.abort(); + expect(await first).toBeInstanceOf(MCodeAbortError); + // The Thread accepts a new Turn once the previous process has exited. + await waitFor(() => (thread as unknown as { busy: boolean }).busy === false); + }); +}); + +function summary(item: ThreadItem): string { + return item.type === 'tool_call' + ? `${item.type}:${item.id}:${String(item.toolCall.name)}` + : `${item.type}:${item.id}`; +} diff --git a/packages/code-sdk/test/stream.test.ts b/packages/code-sdk/test/stream.test.ts new file mode 100644 index 000000000..1f340fdd7 --- /dev/null +++ b/packages/code-sdk/test/stream.test.ts @@ -0,0 +1,213 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { + MCodeAbortError, + MCodeProtocolError, + MCodeVersionError, + type ThreadEvent, +} from '../src/index.js'; +import { fakeClient, fixture, fixtureEvents, tempDir } from './helpers.js'; + +const posixOnly = process.platform === 'win32' ? it.skip : it; + +async function collect(events: AsyncIterable): Promise { + const result: ThreadEvent[] = []; + for await (const event of events) result.push(event); + return result; +} + +describe('thread.runStreamed', () => { + it('yields CLI events in order and drops identical repeated snapshot updates', async () => { + const mcode = fakeClient({ FAKE_MCODE_FIXTURE: fixture('success-tools') }); + const thread = mcode.startThread(); + + const { events } = await thread.runStreamed('go'); + const received = await collect(events); + + const recorded = fixtureEvents('success-tools'); + const expected = recorded + .map((event) => event.sequence as number) + .filter((sequence) => ![7, 10, 20, 21].includes(sequence)); + expect(received.map((event) => event.sequence)).toEqual(expected); + expect(received[0]).toEqual(recorded[0]); + expect(received.at(-1)?.type).toBe('exec.completed'); + expect(thread.id).toBe('mvs_2bab54bd1f17409e968db62f8ed2784a'); + }); + + it('keeps repeated text deltas, which are content rather than duplicates', async () => { + const dir = tempDir(); + const base = { schemaVersion: 1, runId: 'r', sessionId: 's', turnId: 't', timestampMs: 1 }; + const item = { id: 'm:message', type: 'agent_message', contentDelta: 'ha' }; + const lines = [ + { ...base, sequence: 1, type: 'item.started', item }, + { ...base, sequence: 2, type: 'item.updated', item }, + { + ...base, + sequence: 3, + type: 'item.completed', + item: { id: 'm:message', type: 'agent_message', content: 'haha' }, + }, + { ...base, sequence: 4, type: 'turn.completed', durationMs: 1 }, + { + ...base, + sequence: 5, + type: 'exec.completed', + result: { + ...base, + type: 'exec.result', + status: 'succeeded', + output: 'haha', + durationMs: 1, + }, + }, + ]; + const path = `${dir}/deltas.jsonl`; + writeFileSync(path, lines.map((line) => JSON.stringify(line)).join('\n')); + + const { events } = await fakeClient({ FAKE_MCODE_FIXTURE: path }) + .startThread() + .runStreamed('x'); + expect((await collect(events)).map((event) => event.sequence)).toEqual([1, 2, 3, 4, 5]); + }); + + it('delivers turn.failed as an ordinary event without throwing', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('failed-upstream'), + FAKE_MCODE_EXIT: '4', + }); + + const { events } = await mcode.startThread().runStreamed('go'); + const types = (await collect(events)).map((event) => event.type); + + expect(types).toEqual([ + 'exec.started', + 'session.started', + 'turn.started', + 'turn.failed', + 'exec.completed', + ]); + }); + + it('throws MCodeProtocolError for a non-JSON stdout line after delivering earlier events', async () => { + const path = `${tempDir()}/broken.jsonl`; + const lines = readFileSync(fixture('success-tools'), 'utf8').split('\n'); + writeFileSync( + path, + [...lines.slice(0, 2), 'Update available: run npm i -g', ...lines.slice(2)].join('\n'), + ); + + const thread = fakeClient({ FAKE_MCODE_FIXTURE: path }).startThread(); + const { events } = await thread.runStreamed('go'); + const received: ThreadEvent[] = []; + const error = await (async () => { + for await (const event of events) received.push(event); + })().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MCodeProtocolError); + expect((error as Error).message).toContain('stdout line 3'); + expect(received.map((event) => event.type)).toEqual(['exec.started', 'session.started']); + expect(thread.id).toBe('mvs_2bab54bd1f17409e968db62f8ed2784a'); + }); + + it('throws MCodeVersionError for an unsupported schemaVersion', async () => { + const dir = tempDir(); + const path = `${dir}/v2.jsonl`; + writeFileSync( + path, + `${JSON.stringify({ schemaVersion: 2, sequence: 1, type: 'exec.started', sessionId: 's' })}\n`, + ); + + const { events } = await fakeClient({ FAKE_MCODE_FIXTURE: path }) + .startThread() + .runStreamed('go'); + + await expect(collect(events)).rejects.toBeInstanceOf(MCodeVersionError); + }); + + it('passes unknown event types through unchanged', async () => { + const dir = tempDir(); + const path = `${dir}/unknown.jsonl`; + const lines = readFileSync(fixture('resumed'), 'utf8').split('\n').filter(Boolean); + const extra = JSON.stringify({ + schemaVersion: 1, + sequence: 99, + timestampMs: 1, + runId: 'r', + sessionId: 'mvs_2bab54bd1f17409e968db62f8ed2784a', + turnId: 't', + type: 'plan.updated', + plan: ['a'], + }); + writeFileSync(path, [lines[0], extra, ...lines.slice(1)].join('\n')); + + const { events } = await fakeClient({ FAKE_MCODE_FIXTURE: path }) + .startThread() + .runStreamed('go'); + const received = await collect(events); + + expect(received[1]).toMatchObject({ type: 'plan.updated', plan: ['a'] }); + }); + + posixOnly('delivers a cancelled Turn as events when the CLI exits gracefully', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'wait-sigint', + FAKE_MCODE_SPLIT: '9', + }); + const controller = new AbortController(); + const { events } = await mcode + .startThread() + .runStreamed('sleep', { signal: controller.signal }); + + const types: string[] = []; + for await (const event of events) { + types.push(event.type); + if (event.type === 'item.updated' && event.item.toolCall?.status === 1) controller.abort(); + } + + expect(types.slice(-2)).toEqual(['turn.failed', 'exec.completed']); + }); + + posixOnly('cancels the CLI when the consumer stops iterating early', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'wait-sigint', + FAKE_MCODE_SPLIT: '3', + }); + const thread = mcode.startThread(); + const { events } = await thread.runStreamed('sleep'); + + for await (const event of events) { + if (event.type === 'turn.started') break; + } + + // The interrupted process exits and the Thread becomes available again. + const busy = (): boolean => (thread as unknown as { busy: boolean }).busy; + const deadline = Date.now() + 5_000; + while (busy() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + expect(busy()).toBe(false); + }); + + posixOnly('throws AbortError when the CLI is killed before reporting a result', async () => { + const mcode = fakeClient({ + FAKE_MCODE_FIXTURE: fixture('cancelled'), + FAKE_MCODE_MODE: 'hang', + FAKE_MCODE_SPLIT: '3', + }); + const controller = new AbortController(); + const { events } = await mcode + .startThread() + .runStreamed('sleep', { signal: controller.signal }); + + const error = await (async () => { + for await (const event of events) { + if (event.type === 'turn.started') controller.abort(); + } + })().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MCodeAbortError); + expect((error as MCodeAbortError).signal).toBe('SIGINT'); + }); +}); diff --git a/packages/code-sdk/tsconfig.check.json b/packages/code-sdk/tsconfig.check.json new file mode 100644 index 000000000..66d9cde91 --- /dev/null +++ b/packages/code-sdk/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["test/cli-contract.test.ts"] +} diff --git a/packages/code-sdk/tsconfig.json b/packages/code-sdk/tsconfig.json new file mode 100644 index 000000000..733aa2455 --- /dev/null +++ b/packages/code-sdk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "incremental": true, + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 490a2bbae..5c85279fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -491,6 +491,12 @@ importers: specifier: 4.1.11 version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + packages/code-sdk: + devDependencies: + '@minimax-ai/code': + specifier: link:test/fixtures/cli-package + version: link:test/fixtures/cli-package + packages/config: dependencies: '@mavis/shared': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f6ec0b4e..648f3ee2e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,3 +30,4 @@ packages: - packages/oauth-core - packages/oauth-lease-protocol - packages/mcode-tools-host + - packages/code-sdk diff --git a/release/extraction.json b/release/extraction.json index 6b8de845d..b6cdd4173 100644 --- a/release/extraction.json +++ b/release/extraction.json @@ -19,6 +19,7 @@ "packages/agent-runtime", "packages/agent-tools", "packages/browser-core", + "packages/code-sdk", "packages/config", "packages/local-runtime", "packages/local-runtime-v2", diff --git a/release/public-source.json b/release/public-source.json index df74562df..4a8f6ac13 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -479,6 +479,41 @@ "packages/code-acp/README.md", "packages/code-acp/acp.js", "packages/code-acp/package.json", + "packages/code-sdk/CHANGELOG.md", + "packages/code-sdk/CONTRIBUTING.md", + "packages/code-sdk/LICENSE", + "packages/code-sdk/README.md", + "packages/code-sdk/RELEASING.md", + "packages/code-sdk/package.json", + "packages/code-sdk/scripts/release-check.mjs", + "packages/code-sdk/src/args.ts", + "packages/code-sdk/src/cli-resolver.ts", + "packages/code-sdk/src/errors.ts", + "packages/code-sdk/src/exec-process.ts", + "packages/code-sdk/src/index.ts", + "packages/code-sdk/src/items.ts", + "packages/code-sdk/src/mcode.ts", + "packages/code-sdk/src/project-bin.ts", + "packages/code-sdk/src/protocol.ts", + "packages/code-sdk/src/thread.ts", + "packages/code-sdk/src/types.ts", + "packages/code-sdk/src/version.ts", + "packages/code-sdk/test/args.test.ts", + "packages/code-sdk/test/cli-contract.test.ts", + "packages/code-sdk/test/cli-resolution.test.ts", + "packages/code-sdk/test/exit-codes.test.ts", + "packages/code-sdk/test/fixtures/cancelled.jsonl", + "packages/code-sdk/test/fixtures/cli-package/mcode.mjs", + "packages/code-sdk/test/fixtures/cli-package/package.json", + "packages/code-sdk/test/fixtures/failed-upstream.jsonl", + "packages/code-sdk/test/fixtures/resumed.jsonl", + "packages/code-sdk/test/fixtures/success-tools.jsonl", + "packages/code-sdk/test/helpers.ts", + "packages/code-sdk/test/project-bin.test.ts", + "packages/code-sdk/test/run.test.ts", + "packages/code-sdk/test/stream.test.ts", + "packages/code-sdk/tsconfig.check.json", + "packages/code-sdk/tsconfig.json", "packages/config/package.json", "packages/config/src/agent-capabilities.ts", "packages/config/src/agent-runtime-config.ts", @@ -3439,6 +3474,7 @@ "scripts/lib/local-runtime-assets.mjs", "scripts/lib/mcode-tools-artifact.mjs", "scripts/lib/package-exports.mjs", + "scripts/lib/public-text-policy.mjs", "scripts/lib/release-metadata.mjs", "scripts/lib/retired-sources.mjs", "scripts/lib/source-archive.mjs", diff --git a/scripts/lib/public-text-policy.mjs b/scripts/lib/public-text-policy.mjs new file mode 100644 index 000000000..4de11fc70 --- /dev/null +++ b/scripts/lib/public-text-policy.mjs @@ -0,0 +1,6 @@ +// Text that must never appear in published source or npm packages. Shared by +// `check:source` and package release checks so the two cannot drift apart. +export const internalSourceReference = + /(?:[\w.-]+\.xaminim\.com|weaver\/idl|@mavis\/thrift-gen|\/Users\/minimax(?:\/|\b)|\/archon\/internal\/api\/)/u; +export const embeddedCredential = + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n]{100,}|\b(?:ghp_|github_pat_)[A-Za-z0-9_]{30,}|\bsk-[A-Za-z0-9_-]{32,}/u; diff --git a/scripts/source-inventory.mjs b/scripts/source-inventory.mjs index db7f40394..cc12b5b59 100644 --- a/scripts/source-inventory.mjs +++ b/scripts/source-inventory.mjs @@ -6,6 +6,10 @@ import { createHash } from "node:crypto"; import { inventoryPath, readExtraction } from "./lib/release-metadata.mjs"; import { retiredSourceRoots } from "./lib/retired-sources.mjs"; import { readSuites, suiteInventoryViolations } from "./lib/vitest-suites.mjs"; +import { + embeddedCredential as credential, + internalSourceReference as internalText, +} from "./lib/public-text-policy.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const skipped = new Set([ @@ -43,16 +47,13 @@ if ( violations.push( "LICENSE: expected MIT text with the reviewed MiniMax Code attribution", ); -const internalText = - /(?:[\w.-]+\.xaminim\.com|weaver\/idl|@mavis\/thrift-gen|\/Users\/minimax(?:\/|\b)|\/archon\/internal\/api\/)/u; -const credential = - /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n]{100,}|\b(?:ghp_|github_pat_)[A-Za-z0-9_]{30,}|\bsk-[A-Za-z0-9_-]{32,}/u; for (const file of files) { if (retiredSourceRoots.some((prefix) => file.startsWith(prefix))) violations.push(`${file}: retired source`); if (/^(?:.*\/)?\.env(?:\.|$)/u.test(file) && !file.endsWith(".env.example")) violations.push(`${file}: environment file`); - if (file === "scripts/source-inventory.mjs") continue; + // The policy module defines the patterns and would match itself. + if (file === "scripts/lib/public-text-policy.mjs") continue; let content = readFileSync(path.join(root, file)); if (file.endsWith(".gz")) content = gunzipSync(content); const text = content.toString("utf8"); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 0ca4761d3..8cfb24b99 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -56,6 +56,11 @@ const steps = [ { name: "check:standalone", script: "check:standalone", windows: true }, { name: "test:artifact", script: "test:artifact", windows: true }, { name: "test:capabilities", script: "test:capabilities" }, + // The SDK is outside the CLI compiler graph; its type check is small enough to + // run in every Linux/macOS profile. + { name: "typecheck:code-sdk", script: "typecheck:code-sdk" }, + // The SDK cancels `mcode` with POSIX signals; Windows only supports a hard kill. + { name: "test:code-sdk", script: "test:code-sdk", platforms: ["darwin", "linux"] }, { name: "test:windows", script: "test:windows", platforms: ["win32"], windows: true }, { name: "test:status-contract", script: "test:status-contract" }, { name: "test:smoke", script: "test:smoke" }, diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 157c81564..ec8d1cec2 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -235,6 +235,15 @@ "sandbox": [ "packages/local-runtime-v2/src/service/sandbox/backend/srt-macos.test.ts", "test/executable-resolution.test.ts" + ], + "code-sdk": [ + "packages/code-sdk/test/args.test.ts", + "packages/code-sdk/test/cli-contract.test.ts", + "packages/code-sdk/test/cli-resolution.test.ts", + "packages/code-sdk/test/exit-codes.test.ts", + "packages/code-sdk/test/project-bin.test.ts", + "packages/code-sdk/test/run.test.ts", + "packages/code-sdk/test/stream.test.ts" ] } } diff --git a/tsconfig.standalone.json b/tsconfig.standalone.json index 5af7034ee..12c4f809f 100644 --- a/tsconfig.standalone.json +++ b/tsconfig.standalone.json @@ -376,6 +376,12 @@ "@mavis/system-reminder": [ "./packages/agent-modules/system-reminder/src/index.ts" ], + "@minimax-ai/code-sdk": [ + "./packages/code-sdk/src/index.ts" + ], + "@minimax-ai/code-sdk/package.json": [ + "./packages/code-sdk/package.json" + ], "@minimax/code/package.json": [ "./packages/tui/package.json" ], From 701cb82819f3d68ebbd47ae5ce75d98e787fa242 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 17:44:09 +0800 Subject: [PATCH 02/10] fix(code-sdk): omit the publish command from release:check rehearsals A rehearsal on a branch other than an up-to-date main now ends with an explicit warning instead of a publish command for a tarball that is not release-ready. --- packages/code-sdk/scripts/release-check.mjs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/code-sdk/scripts/release-check.mjs b/packages/code-sdk/scripts/release-check.mjs index eab3f2604..38d9adea0 100644 --- a/packages/code-sdk/scripts/release-check.mjs +++ b/packages/code-sdk/scripts/release-check.mjs @@ -216,10 +216,17 @@ const smoke = run( ); console.log(`✓ ${smoke.stdout.trim()}`); -console.log(`\n✓ ${spec} is ready. The tarball was checked but nothing was published.`); -console.log('\nPublish it yourself (npm login with 2FA required), from this directory:\n'); -console.log(` npm publish ${tarball} --registry ${REGISTRY} --access public --tag latest`); +const rehearsal = branch !== RELEASE_BRANCH || head !== remoteMain; console.log( - `\nThen read it back:\n\n npm view ${spec} version dist.integrity --registry ${REGISTRY} # expect ${integrity}`, + rehearsal + ? `\n! Rehearsal passed for ${spec}. Do not publish this tarball: release from an up-to-date ${RELEASE_BRANCH}.` + : `\n✓ ${spec} is ready. The tarball was checked but nothing was published.`, ); -console.log(` npm view ${name} dist-tags --registry ${REGISTRY} # expect latest: ${version}`); +if (!rehearsal) { + console.log('\nPublish it yourself (npm login with 2FA required):\n'); + console.log(` npm publish ${tarball} --registry ${REGISTRY} --access public --tag latest`); + console.log( + `\nThen read it back:\n\n npm view ${spec} version dist.integrity --registry ${REGISTRY} # expect ${integrity}`, + ); + console.log(` npm view ${name} dist-tags --registry ${REGISTRY} # expect latest: ${version}`); +} From f441c7d6f8c55f172e66d3bbc5f726b18ea3190d Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 18:06:05 +0800 Subject: [PATCH 03/10] fix(code-sdk): never start the CLI through cmd.exe Drop the Windows fallback that ran an unrecognized .cmd launcher through cmd.exe with escaped arguments. The SDK now only runs a JS entry with process.execPath or a native executable, and asks for an explicit bin when a .cmd shim has no recognizable JS entry. This removes the shell command construction and the backslash-heavy quoting regular expression flagged by CodeQL. --- packages/code-sdk/README.md | 5 +- packages/code-sdk/src/cli-resolver.ts | 46 +++++-------------- packages/code-sdk/src/exec-process.ts | 1 - packages/code-sdk/src/project-bin.ts | 9 ++-- packages/code-sdk/src/thread.ts | 2 +- packages/code-sdk/src/version.ts | 3 +- packages/code-sdk/test/cli-resolution.test.ts | 27 ++++++----- 7 files changed, 34 insertions(+), 59 deletions(-) diff --git a/packages/code-sdk/README.md b/packages/code-sdk/README.md index 125d28040..f33d65c77 100644 --- a/packages/code-sdk/README.md +++ b/packages/code-sdk/README.md @@ -42,8 +42,9 @@ The SDK looks for the CLI in this order: `mcode.ps1` on Windows) found walking up from `process.cwd()`. The SDK prefers the JS entry behind the symlink or npm/pnpm shim and runs it with `process.execPath`, so the CLI package name does not matter. -4. `mcode` on `PATH` (`mcode.cmd` on Windows: the JS entry behind an npm/pnpm shim is preferred; - otherwise it is started through `cmd.exe` with strictly escaped arguments). +4. `mcode` on `PATH`. On Windows, `mcode.cmd` is used through the JS entry behind its npm/pnpm + shim; the SDK never starts the CLI through a shell, so a `.cmd` without a recognizable JS entry + fails with `MCodeNotFoundError` and you should pass the JS entry (or `mcode.exe`) as `bin`. Before the first run, each `MCode` instance runs `mcode --version` once and caches the result. A CLI older than `MIN_CLI_VERSION` (currently `0.7.0`) fails with `MCodeVersionError`. Only diff --git a/packages/code-sdk/src/cli-resolver.ts b/packages/code-sdk/src/cli-resolver.ts index 617862921..92bedb6d2 100644 --- a/packages/code-sdk/src/cli-resolver.ts +++ b/packages/code-sdk/src/cli-resolver.ts @@ -9,10 +9,10 @@ export { parseCmdShim, parseShellShim } from './project-bin.js'; export const CLI_PACKAGE_NAME = '@minimax-ai/code'; -/** How to start the CLI. `cmd` is the Windows `.cmd` fallback that needs cmd.exe. */ +/** How to start the CLI. The SDK never starts it through a shell. */ export interface ResolvedCli { - readonly kind: 'node' | 'exec' | 'cmd'; - /** JS entry (`node`), executable (`exec`) or `.cmd` file (`cmd`). */ + readonly kind: 'node' | 'exec'; + /** JS entry run with `process.execPath` (`node`), or a native executable (`exec`). */ readonly path: string; readonly source: 'option' | 'package' | 'project' | 'path'; } @@ -20,7 +20,6 @@ export interface ResolvedCli { export interface SpawnCommand { readonly command: string; readonly args: string[]; - readonly windowsVerbatimArguments?: boolean; } export interface ResolveCliOptions { @@ -121,40 +120,19 @@ function fromExecutable( } if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) { const entry = parseCmdShim(readFileSync(path, 'utf8'), path); - return entry ? { kind: 'node', path: entry, source } : { kind: 'cmd', path, source }; + if (entry) return { kind: 'node', path: entry, source }; + // Running a batch file would need cmd.exe and shell quoting; ask for the JS entry instead. + throw new MCodeNotFoundError( + `Cannot find the JS entry behind ${path}; pass the CLI's JS entry (or mcode.exe) as \`bin\`.`, + ); } return { kind: 'exec', path, source }; } -export function buildSpawnCommand( - cli: ResolvedCli, - args: readonly string[], - env: Readonly> = process.env, -): SpawnCommand { - if (cli.kind === 'node') return { command: process.execPath, args: [cli.path, ...args] }; - if (cli.kind === 'exec') return { command: cli.path, args: [...args] }; - const line = [escapeCmdCommand(cli.path), ...args.map((arg) => escapeCmdArgument(arg))].join(' '); - return { - command: readEnv(env, 'ComSpec', 'win32') ?? 'cmd.exe', - args: ['/d', '/s', '/c', `"${line}"`], - windowsVerbatimArguments: true, - }; -} - -const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/gu; - -export function escapeCmdCommand(value: string): string { - return value.replace(CMD_META_CHARS, '^$1'); -} - -/** - * Quotes one argument for cmd.exe running a batch shim. Batch files re-parse `%*`, so meta - * characters are caret-escaped twice (same strategy as cross-spawn). - */ -export function escapeCmdArgument(value: string): string { - let quoted = value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\*)$/u, '$1$1'); - quoted = `"${quoted}"`; - return quoted.replace(CMD_META_CHARS, '^$1').replace(CMD_META_CHARS, '^$1'); +export function buildSpawnCommand(cli: ResolvedCli, args: readonly string[]): SpawnCommand { + return cli.kind === 'node' + ? { command: process.execPath, args: [cli.path, ...args] } + : { command: cli.path, args: [...args] }; } function readEnv( diff --git a/packages/code-sdk/src/exec-process.ts b/packages/code-sdk/src/exec-process.ts index 74643656b..4fb9577e7 100644 --- a/packages/code-sdk/src/exec-process.ts +++ b/packages/code-sdk/src/exec-process.ts @@ -96,7 +96,6 @@ export class ExecProcess { env: options.env as NodeJS.ProcessEnv, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - ...(command.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), }); this.sessionId = options.sessionId; trackChild(this.child); diff --git a/packages/code-sdk/src/project-bin.ts b/packages/code-sdk/src/project-bin.ts index 33cec3414..e07fb576a 100644 --- a/packages/code-sdk/src/project-bin.ts +++ b/packages/code-sdk/src/project-bin.ts @@ -37,9 +37,8 @@ function resolveLauncher(launcher: string, platform: NodeJS.Platform): ResolvedC const head = readHead(launcher); const entry = extension === '.cmd' ? parseCmdShim(head, launcher) : parseShellShim(head, launcher); - if (entry) return { kind: 'node', path: entry, source: 'project' }; - // PowerShell shims are only usable through their JS entry. - return extension === '.cmd' ? { kind: 'cmd', path: launcher, source: 'project' } : undefined; + // Shims are only used through their JS entry; the SDK never starts a shell. + return entry ? { kind: 'node', path: entry, source: 'project' } : undefined; } // npm links `.bin/mcode` to the package's JS entry; pnpm writes a shell shim. const real = realpathSync(launcher); @@ -55,8 +54,8 @@ function resolveLauncher(launcher: string, platform: NodeJS.Platform): ResolvedC } /** - * Extracts the JS entry from an npm / pnpm generated `.cmd` shim, so the CLI can run with - * `process.execPath` instead of cmd.exe. + * Extracts the JS entry from an npm / pnpm generated `.cmd` shim, so the CLI runs with + * `process.execPath` instead of through cmd.exe. */ export function parseCmdShim(content: string, shimPath: string): string | undefined { const match = /"%~?dp0%?\\?([^"%]+\.(?:c|m)?js)"/iu.exec(content); diff --git a/packages/code-sdk/src/thread.ts b/packages/code-sdk/src/thread.ts index e01447586..d373954a5 100644 --- a/packages/code-sdk/src/thread.ts +++ b/packages/code-sdk/src/thread.ts @@ -143,7 +143,7 @@ export class Thread { }); const items = new ItemAccumulator(); const process = new ExecProcess({ - command: buildSpawnCommand(cli, args, env), + command: buildSpawnCommand(cli, args), env, prompt: input, ...(options.signal ? { signal: options.signal } : {}), diff --git a/packages/code-sdk/src/version.ts b/packages/code-sdk/src/version.ts index 309137da4..987bfbd0f 100644 --- a/packages/code-sdk/src/version.ts +++ b/packages/code-sdk/src/version.ts @@ -67,7 +67,7 @@ function readVersionOutput( cli: ResolvedCli, env: Readonly>, ): Promise { - const command = buildSpawnCommand(cli, ['--version'], env); + const command = buildSpawnCommand(cli, ['--version']); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; @@ -75,7 +75,6 @@ function readVersionOutput( env: env as NodeJS.ProcessEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - ...(command.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), }); const timer = setTimeout(() => child.kill('SIGKILL'), VERSION_TIMEOUT_MS); child.stdout.setEncoding('utf8').on('data', (chunk: string) => { diff --git a/packages/code-sdk/test/cli-resolution.test.ts b/packages/code-sdk/test/cli-resolution.test.ts index b861c4591..1e95025ad 100644 --- a/packages/code-sdk/test/cli-resolution.test.ts +++ b/packages/code-sdk/test/cli-resolution.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it } from 'vitest'; import { MCode, MCodeNotFoundError, MCodeVersionError } from '../src/index.js'; import { buildSpawnCommand, - escapeCmdArgument, findOnPath, parseCmdShim, resolveCli, @@ -104,20 +103,20 @@ describe('CLI resolution', () => { expect(parseCmdShim('@echo off\r\nnative.exe %*', shim)).toBeUndefined(); }); - it('escapes arguments for the cmd.exe fallback', () => { - expect(escapeCmdArgument('--cwd=C:\\a b')).toBe('^^^"--cwd=C:\\a^^^ b^^^"'); - expect(escapeCmdArgument('x&y|z')).toBe('^^^"x^^^&y^^^|z^^^"'); - const command = buildSpawnCommand( - { kind: 'cmd', path: 'C:\\bin\\mcode.cmd', source: 'path' }, - ['exec'], - { - ComSpec: 'C:\\Windows\\cmd.exe', - }, + it('never starts a batch file through a shell', () => { + const dir = tempDir(); + const shim = join(dir, 'mcode.cmd'); + writeFileSync(shim, '@echo off\r\nnative-launcher.exe %*\r\n'); + + // Without a resolvable JS entry the SDK asks for one instead of falling back to cmd.exe. + expect(() => resolveCli({ bin: shim, env: {}, platform: 'win32' })).toThrow( + /Cannot find the JS entry behind .*mcode\.cmd/u, ); - expect(command).toEqual({ - command: 'C:\\Windows\\cmd.exe', - args: ['/d', '/s', '/c', '"C:\\bin\\mcode.cmd ^^^"exec^^^""'], - windowsVerbatimArguments: true, + expect( + buildSpawnCommand({ kind: 'exec', path: 'C:\\bin\\mcode.exe', source: 'path' }, ['exec']), + ).toEqual({ + command: 'C:\\bin\\mcode.exe', + args: ['exec'], }); }); }); From d2506b3744abfba4304e76ea1e1922eb48c58c2c Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 19:19:18 +0800 Subject: [PATCH 04/10] fix(code-sdk): pin the npmjs scope registry in release checks publishConfig.registry and --registry only replace the default registry; an @minimax-ai:registry setting in any npmrc still routed publish and the read-back of this scoped package elsewhere, so a mistaken publish would also read back as consistent. release:check now reads the effective npm configuration and fails while @minimax-ai:registry points anywhere but npmjs. Every npm command it runs or prints, and every command in RELEASING.md, passes both --registry and --@minimax-ai:registry for npmjs. Tools start without a shell; on Windows npm and pnpm run through their JS entries so the smoke-test script and temporary paths are not split by cmd.exe. --- packages/code-sdk/RELEASING.md | 49 ++++++--- packages/code-sdk/scripts/release-check.mjs | 70 +++++++++--- packages/code-sdk/scripts/release-tools.mjs | 100 +++++++++++++++++ packages/code-sdk/test/release-check.test.ts | 106 +++++++++++++++++++ packages/code-sdk/tsconfig.check.json | 3 +- release/public-source.json | 2 + test/vitest-suites.json | 1 + 7 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 packages/code-sdk/scripts/release-tools.mjs create mode 100644 packages/code-sdk/test/release-check.test.ts diff --git a/packages/code-sdk/RELEASING.md b/packages/code-sdk/RELEASING.md index dd90c225e..aa94a1df3 100644 --- a/packages/code-sdk/RELEASING.md +++ b/packages/code-sdk/RELEASING.md @@ -6,9 +6,12 @@ This file is not part of the npm package. ## Rules -- Publish only to `https://registry.npmjs.org/`. `package.json#publishConfig` pins that registry and - public access, so `npm publish` does not follow a different default registry configured on your - machine. Always pass `--registry https://registry.npmjs.org/` explicitly as well. +- Publish only to `https://registry.npmjs.org/`. `package.json#publishConfig.registry` and + `--registry` only replace the **default** registry. A scoped setting such as + `@minimax-ai:registry=` in any npmrc still wins for this `@minimax-ai/*` package, for + `npm publish` as well as for `npm view`. Every npm command below therefore passes both + `--registry=https://registry.npmjs.org/` and `--@minimax-ai:registry=https://registry.npmjs.org/`, + and `release:check` refuses to run while a different `@minimax-ai:registry` is configured. - A published version is immutable. Never try to republish or overwrite an exact version; fix problems in a new version. Prefer `npm deprecate` over `npm unpublish` for a broken release. - Versions are stable `X.Y.Z`. There is no prerelease channel yet. @@ -34,28 +37,36 @@ pnpm --filter @minimax-ai/code-sdk run release:check `release:check` never publishes. It: -1. requires a clean working tree on `main` equal to `origin/main`; -2. checks that the version is `X.Y.Z`, matches the first CHANGELOG section, and is not on npm yet; +1. reads the effective npm configuration and fails if `@minimax-ai:registry` points anywhere other + than npmjs (check with `npm config get @minimax-ai:registry`; remove it with + `npm config delete @minimax-ai:registry` or override it for the release); +2. requires a clean working tree on `main` equal to `origin/main`; +3. checks that the version is `X.Y.Z`, matches the first CHANGELOG section, and is not on npm yet; that the package is not private, pins `publishConfig`, has no runtime dependencies and publishes only `dist`, `src`, `README.md` and `CHANGELOG.md` (npm adds `package.json` and `LICENSE`); -3. builds, type-checks and runs the SDK test suite; -4. runs `npm pack`, rejects unexpected tarball entries, and scans every packed file for internal +4. builds, type-checks and runs the SDK test suite; +5. runs `npm pack`, rejects unexpected tarball entries, and scans every packed file for internal host names, credentials, local user paths (`/Users/…`), other repository package paths and internal package names; -5. installs the tarball into an empty project and checks both `import` and `require`; -6. prints the exact `npm publish` command for the checked tarball and the read-back commands. +6. installs the tarball into an empty project and checks both `import` and `require`; +7. prints the exact `npm publish` command for the checked tarball and the read-back commands. -Use `-- --allow-non-main` only to rehearse on a feature branch; its output is not release-ready. -Use `-- --offline` to skip the npm lookup when the registry is unreachable. +Use `-- --allow-non-main` only to rehearse on a feature branch; it ends with a warning instead of a +publish command. Use `-- --offline` to skip the npm lookup when the registry is unreachable. + +The check runs on macOS and Linux, where it has been exercised. It starts every tool without a +shell; on Windows it runs npm and pnpm through their JS entries (`npm-cli.js` next to Node.js and +the pnpm script that started it), which has not been exercised on a Windows machine yet. ## 3. Publish Run the printed command yourself. It publishes the tarball that was just checked: ```bash -npm login --registry https://registry.npmjs.org/ # if needed; 2FA is required +npm login --registry=https://registry.npmjs.org/ # if needed; 2FA is required npm publish /tmp/code-sdk-release-XXXX/minimax-ai-code-sdk-X.Y.Z.tgz \ - --registry https://registry.npmjs.org/ --access public --tag latest + --registry=https://registry.npmjs.org/ --@minimax-ai:registry=https://registry.npmjs.org/ \ + --access public --tag latest ``` npm may keep a new version in validation for a while; `npm view` can return 404 until it is @@ -64,8 +75,10 @@ available. ## 4. Read back ```bash -npm view @minimax-ai/code-sdk@X.Y.Z version dist.integrity --registry https://registry.npmjs.org/ -npm view @minimax-ai/code-sdk dist-tags --registry https://registry.npmjs.org/ +npm view @minimax-ai/code-sdk@X.Y.Z version dist.integrity \ + --registry=https://registry.npmjs.org/ --@minimax-ai:registry=https://registry.npmjs.org/ +npm view @minimax-ai/code-sdk dist-tags \ + --registry=https://registry.npmjs.org/ --@minimax-ai:registry=https://registry.npmjs.org/ ``` The integrity must equal the value printed by `release:check`, and `latest` must point to @@ -77,7 +90,8 @@ tarball. Investigate and publish a new version; never overwrite. ```bash smoke="$(mktemp -d)" && cd "$smoke" echo '{"name":"code-sdk-smoke","private":true}' > package.json -npm install @minimax-ai/code-sdk@X.Y.Z --registry https://registry.npmjs.org/ --no-package-lock +npm install @minimax-ai/code-sdk@X.Y.Z --no-package-lock \ + --registry=https://registry.npmjs.org/ --@minimax-ai:registry=https://registry.npmjs.org/ node --input-type=module -e "const sdk = await import('@minimax-ai/code-sdk'); console.log(typeof sdk.MCode)" node -e "console.log(typeof require('@minimax-ai/code-sdk').MCode)" ``` @@ -103,4 +117,5 @@ move or delete a pushed release tag. publish again unless `npm view` confirms the version does not exist. - **Read-back shows different integrity, or the smoke test fails**: the version is published and cannot be replaced. Release a fixed version; deprecate the broken one with - `npm deprecate @minimax-ai/code-sdk@X.Y.Z ""` if users should avoid it. + `npm deprecate @minimax-ai/code-sdk@X.Y.Z "" --registry=https://registry.npmjs.org/ --@minimax-ai:registry=https://registry.npmjs.org/` + if users should avoid it. diff --git a/packages/code-sdk/scripts/release-check.mjs b/packages/code-sdk/scripts/release-check.mjs index 38d9adea0..736d17984 100644 --- a/packages/code-sdk/scripts/release-check.mjs +++ b/packages/code-sdk/scripts/release-check.mjs @@ -21,6 +21,17 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + NPMJS_REGISTRY, + NPMJS_TARGET_ARGS, + normalizeRegistry, + PACKAGE_SCOPE, + publishCommand, + readBackCommands, + resolveTool, + scopedRegistryProblem, +} from './release-tools.mjs'; + const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(packageDir, '../..'); // The repository's own publication rules (also used by `check:source`); this script runs only @@ -28,7 +39,7 @@ const repoRoot = path.resolve(packageDir, '../..'); const { embeddedCredential, internalSourceReference } = await import( pathToFileURL(path.join(repoRoot, 'scripts/lib/public-text-policy.mjs')).href ); -const REGISTRY = 'https://registry.npmjs.org/'; +const REGISTRY = NPMJS_REGISTRY; const RELEASE_BRANCH = 'main'; const PUBLISHED_FILES = ['dist', 'src', 'README.md', 'CHANGELOG.md']; const ALLOWED_ENTRY = @@ -64,22 +75,34 @@ function step(message) { console.log(`\n▸ ${message}`); } -function run(command, commandArgs, { cwd = repoRoot, capture = false, allowFailure = false } = {}) { - const result = spawnSync(command, commandArgs, { +function run(tool, commandArgs, { cwd = repoRoot, capture = false, allowFailure = false } = {}) { + let resolved; + try { + resolved = resolveTool(tool); + } catch (error) { + fail(error.message); + } + // Never through a shell: arguments such as the smoke-test script and temporary paths are + // passed to the process verbatim on every platform. + const result = spawnSync(resolved.command, [...resolved.args, ...commandArgs], { cwd, encoding: 'utf8', stdio: capture || allowFailure ? ['ignore', 'pipe', 'pipe'] : 'inherit', - shell: process.platform === 'win32', maxBuffer: 64 * 1024 * 1024, }); + if (result.error) fail(`${tool} could not be started: ${result.error.message}`); if (result.status !== 0 && !allowFailure) { fail( - `${command} ${commandArgs.join(' ')} failed${capture ? `: ${`${result.stdout}${result.stderr}`.trim()}` : ''}`, + `${tool} ${commandArgs.join(' ')} failed${capture ? `: ${`${result.stdout}${result.stderr}`.trim()}` : ''}`, ); } return result; } +const shellWord = (value) => + /^[\w@%+=:,./-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +const printable = (command) => command.map(shellWord).join(' '); + function git(commandArgs) { return run('git', commandArgs, { capture: true }).stdout.trim(); } @@ -95,6 +118,18 @@ const manifest = JSON.parse(readFileSync(path.join(packageDir, 'package.json'), const { name, version } = manifest; const spec = `${name}@${version}`; +step('npm registry configuration'); +const npmConfig = (key) => + normalizeRegistry(run('npm', ['config', 'get', key], { cwd: packageDir, capture: true }).stdout); +const scopedRegistry = npmConfig(`${PACKAGE_SCOPE}:registry`); +const defaultRegistry = npmConfig('registry'); +const registryProblem = scopedRegistryProblem(scopedRegistry); +if (registryProblem) fail(registryProblem); +console.log( + `✓ ${PACKAGE_SCOPE}:registry ${scopedRegistry || 'is not set'}; default registry ${defaultRegistry || 'is not set'}` + + ` (npm commands below pin ${NPMJS_TARGET_ARGS.join(' ')})`, +); + step('Checkout'); const status = git(['status', '--porcelain']); if (status) fail(`The working tree must be clean; found: ${status.split('\n')[0]}`); @@ -137,7 +172,7 @@ console.log(`✓ ${spec}, CHANGELOG first section ${firstRelease}, publishConfig if (args.has('--offline')) { console.log('! Skipped the npm registry lookup (--offline)'); } else { - const view = run('npm', ['view', spec, 'version', '--registry', REGISTRY], { + const view = run('npm', ['view', spec, 'version', ...NPMJS_TARGET_ARGS], { capture: true, allowFailure: true, }); @@ -153,7 +188,7 @@ if (args.has('--offline')) { step('Build, type check and tests'); run('pnpm', ['--filter', name, 'run', 'build']); run('pnpm', ['--filter', name, 'run', 'typecheck']); -run(process.execPath, ['scripts/run-vitest-suite.mjs', 'code-sdk']); +run('node', ['scripts/run-vitest-suite.mjs', 'code-sdk']); step('Pack and scan the tarball'); const outDir = mkdtempSync(path.join(tmpdir(), 'code-sdk-release-')); @@ -193,14 +228,22 @@ mkdirSync(project); writeFileSync(path.join(project, 'package.json'), '{"name":"code-sdk-smoke","private":true}\n'); run( 'npm', - ['install', tarball, '--no-audit', '--no-fund', '--ignore-scripts', '--no-package-lock'], + [ + 'install', + tarball, + '--no-audit', + '--no-fund', + '--ignore-scripts', + '--no-package-lock', + ...NPMJS_TARGET_ARGS, + ], { cwd: project, capture: true, }, ); const smoke = run( - process.execPath, + 'node', [ '--input-type=module', '-e', @@ -224,9 +267,8 @@ console.log( ); if (!rehearsal) { console.log('\nPublish it yourself (npm login with 2FA required):\n'); - console.log(` npm publish ${tarball} --registry ${REGISTRY} --access public --tag latest`); - console.log( - `\nThen read it back:\n\n npm view ${spec} version dist.integrity --registry ${REGISTRY} # expect ${integrity}`, - ); - console.log(` npm view ${name} dist-tags --registry ${REGISTRY} # expect latest: ${version}`); + console.log(` ${printable(publishCommand(tarball))}`); + const [viewVersion, viewTags] = readBackCommands(spec, name); + console.log(`\nThen read it back:\n\n ${printable(viewVersion)} # expect ${integrity}`); + console.log(` ${printable(viewTags)} # expect latest: ${version}`); } diff --git a/packages/code-sdk/scripts/release-tools.mjs b/packages/code-sdk/scripts/release-tools.mjs new file mode 100644 index 000000000..124420d1b --- /dev/null +++ b/packages/code-sdk/scripts/release-tools.mjs @@ -0,0 +1,100 @@ +/** + * Helpers for release-check.mjs, kept free of side effects so they can be unit tested. + */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +export const NPMJS_REGISTRY = 'https://registry.npmjs.org/'; +export const PACKAGE_SCOPE = '@minimax-ai'; + +/** + * Every npm command that talks to a registry gets both flags. `publishConfig.registry` and + * `--registry` only replace the default registry; a configured `@minimax-ai:registry` would still + * win for this scoped package, so the scope is pinned explicitly as well. + */ +export const NPMJS_TARGET_ARGS = Object.freeze([ + `--registry=${NPMJS_REGISTRY}`, + `--${PACKAGE_SCOPE}:registry=${NPMJS_REGISTRY}`, +]); + +/** Normalizes `npm config get` output; npm prints `undefined` for unset keys. */ +export function normalizeRegistry(value) { + const text = String(value ?? '').trim(); + if (!text || text === 'undefined' || text === 'null') return ''; + try { + const url = new URL(text); + return url.href.endsWith('/') ? url.href : `${url.href}/`; + } catch { + return text; + } +} + +/** Why the effective npm configuration could publish or read the package elsewhere, if it could. */ +export function scopedRegistryProblem(scopedRegistry) { + const scoped = normalizeRegistry(scopedRegistry); + if (!scoped || scoped === NPMJS_REGISTRY) return undefined; + return ( + `${PACKAGE_SCOPE}:registry is set to ${scoped}. It overrides publishConfig and --registry for ` + + `${PACKAGE_SCOPE} packages, so publish and read-back would use that registry. Remove it ` + + `(npm config delete ${PACKAGE_SCOPE}:registry) or set it to ${NPMJS_REGISTRY}, then run ` + + 'release:check again.' + ); +} + +/** The publish command printed for a maintainer to run. */ +export function publishCommand(tarball) { + return ['npm', 'publish', tarball, ...NPMJS_TARGET_ARGS, '--access', 'public', '--tag', 'latest']; +} + +/** Read-back commands printed after a successful check. */ +export function readBackCommands(spec, name) { + return [ + ['npm', 'view', spec, 'version', 'dist.integrity', ...NPMJS_TARGET_ARGS], + ['npm', 'view', name, 'dist-tags', ...NPMJS_TARGET_ARGS], + ]; +} + +/** + * How to start a tool without a shell. Node.js refuses to spawn `.cmd` files without a shell + * on Windows, so npm and pnpm run through their JS entry with the current Node.js there. + */ +export function resolveTool( + name, + { + platform = process.platform, + env = process.env, + execPath = process.execPath, + exists = existsSync, + } = {}, +) { + if (name === 'node') return { command: execPath, args: [] }; + if (platform !== 'win32') return { command: name, args: [] }; + // git.exe and the bsdtar shipped with Windows 10+ are native executables. + if (name === 'git' || name === 'tar') return { command: name, args: [] }; + const execPathEnv = env.npm_execpath ?? ''; + const runsWith = (file) => + /\.exe$/iu.test(file) ? { command: file, args: [] } : { command: execPath, args: [file] }; + if (name === 'pnpm') { + if ( + /pnpm[^\\/]*$/iu.test(path.win32.basename(execPathEnv)) || + /[\\/]pnpm[\\/]/iu.test(execPathEnv) + ) { + if (exists(execPathEnv)) return runsWith(execPathEnv); + } + throw new Error( + 'On Windows, run release:check through pnpm (pnpm --filter @minimax-ai/code-sdk run release:check).', + ); + } + if (name === 'npm') { + const candidates = [ + /npm-cli\.js$/iu.test(execPathEnv) ? execPathEnv : '', + path.win32.join(path.win32.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ].filter(Boolean); + const cli = candidates.find((candidate) => exists(candidate)); + if (cli) return { command: execPath, args: [cli] }; + throw new Error( + `Cannot find npm-cli.js next to ${execPath}. release:check needs the npm that ships with Node.js on Windows.`, + ); + } + throw new Error(`Unsupported tool ${name}`); +} diff --git a/packages/code-sdk/test/release-check.test.ts b/packages/code-sdk/test/release-check.test.ts new file mode 100644 index 000000000..4ebdc2242 --- /dev/null +++ b/packages/code-sdk/test/release-check.test.ts @@ -0,0 +1,106 @@ +import { spawnSync } from 'node:child_process'; +import { writeFileSync, type PathLike } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + NPMJS_TARGET_ARGS, + normalizeRegistry, + publishCommand, + readBackCommands, + resolveTool, + scopedRegistryProblem, +} from '../scripts/release-tools.mjs'; +import { tempDir } from './helpers.js'; + +const releaseCheck = fileURLToPath(new URL('../scripts/release-check.mjs', import.meta.url)); +const NPMJS = 'https://registry.npmjs.org/'; + +describe('release:check registry safety', () => { + it('pins both the default and the @minimax-ai scoped registry on every printed npm command', () => { + expect(NPMJS_TARGET_ARGS).toEqual([`--registry=${NPMJS}`, `--@minimax-ai:registry=${NPMJS}`]); + for (const command of [ + publishCommand('/tmp/code sdk/minimax-ai-code-sdk-0.1.0.tgz'), + ...readBackCommands('@minimax-ai/code-sdk@0.1.0', '@minimax-ai/code-sdk'), + ]) { + expect(command).toEqual(expect.arrayContaining([...NPMJS_TARGET_ARGS])); + } + expect(publishCommand('x.tgz')).toEqual([ + 'npm', + 'publish', + 'x.tgz', + ...NPMJS_TARGET_ARGS, + '--access', + 'public', + '--tag', + 'latest', + ]); + }); + + it('accepts an unset or npmjs scoped registry and rejects any other', () => { + expect(normalizeRegistry('undefined')).toBe(''); + expect(scopedRegistryProblem('undefined')).toBeUndefined(); + expect(scopedRegistryProblem('https://registry.npmjs.org')).toBeUndefined(); + expect(scopedRegistryProblem('http://127.0.0.1:9/scoped')).toMatch( + /@minimax-ai:registry is set to http:\/\/127\.0\.0\.1:9\/scoped\/\. It overrides publishConfig and --registry/u, + ); + }); + + it('fails before any registry access when the effective npm config scopes @minimax-ai elsewhere', () => { + // An isolated user config reproduces a machine whose npmrc points the scope at a mirror. + const npmrc = join(tempDir(), 'npmrc'); + writeFileSync( + npmrc, + 'registry=http://127.0.0.1:9/default/\n@minimax-ai:registry=http://127.0.0.1:9/scoped/\n', + ); + + const result = spawnSync(process.execPath, [releaseCheck, '--allow-non-main', '--offline'], { + encoding: 'utf8', + env: { ...process.env, npm_config_userconfig: npmrc }, + timeout: 60_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toMatch( + /@minimax-ai:registry is set to http:\/\/127\.0\.0\.1:9\/scoped\//u, + ); + expect(result.stdout).not.toMatch(/Build, type check and tests|npm publish/u); + }); +}); + +describe('release:check tool invocation', () => { + const exists = (files: string[]) => (file: PathLike) => files.includes(String(file)); + + it('starts tools directly on POSIX and runs Node.js itself without a shell', () => { + expect(resolveTool('npm', { platform: 'linux' })).toEqual({ command: 'npm', args: [] }); + expect(resolveTool('node', { platform: 'win32', execPath: 'C:\\node\\node.exe' })).toEqual({ + command: 'C:\\node\\node.exe', + args: [], + }); + }); + + it('runs npm and pnpm through their JS entries on Windows', () => { + const execPath = 'C:\\Program Files\\nodejs\\node.exe'; + const npmCli = 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js'; + const pnpmCli = 'C:\\Users\\me\\AppData\\pnpm\\pnpm.cjs'; + const env = { npm_execpath: pnpmCli }; + expect( + resolveTool('npm', { platform: 'win32', execPath, env, exists: exists([npmCli]) }), + ).toEqual({ + command: execPath, + args: [npmCli], + }); + expect( + resolveTool('pnpm', { platform: 'win32', execPath, env, exists: exists([pnpmCli]) }), + ).toEqual({ + command: execPath, + args: [pnpmCli], + }); + expect(resolveTool('git', { platform: 'win32' })).toEqual({ command: 'git', args: [] }); + expect(() => + resolveTool('npm', { platform: 'win32', execPath, env: {}, exists: exists([]) }), + ).toThrow(/Cannot find npm-cli\.js/u); + }); +}); diff --git a/packages/code-sdk/tsconfig.check.json b/packages/code-sdk/tsconfig.check.json index 66d9cde91..2130b9cec 100644 --- a/packages/code-sdk/tsconfig.check.json +++ b/packages/code-sdk/tsconfig.check.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.node.json", "compilerOptions": { "noEmit": true, - "rootDir": "." + "rootDir": ".", + "allowJs": true }, "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["test/cli-contract.test.ts"] diff --git a/release/public-source.json b/release/public-source.json index 4a8f6ac13..98629a27a 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -486,6 +486,7 @@ "packages/code-sdk/RELEASING.md", "packages/code-sdk/package.json", "packages/code-sdk/scripts/release-check.mjs", + "packages/code-sdk/scripts/release-tools.mjs", "packages/code-sdk/src/args.ts", "packages/code-sdk/src/cli-resolver.ts", "packages/code-sdk/src/errors.ts", @@ -510,6 +511,7 @@ "packages/code-sdk/test/fixtures/success-tools.jsonl", "packages/code-sdk/test/helpers.ts", "packages/code-sdk/test/project-bin.test.ts", + "packages/code-sdk/test/release-check.test.ts", "packages/code-sdk/test/run.test.ts", "packages/code-sdk/test/stream.test.ts", "packages/code-sdk/tsconfig.check.json", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index ec8d1cec2..342aed399 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -242,6 +242,7 @@ "packages/code-sdk/test/cli-resolution.test.ts", "packages/code-sdk/test/exit-codes.test.ts", "packages/code-sdk/test/project-bin.test.ts", + "packages/code-sdk/test/release-check.test.ts", "packages/code-sdk/test/run.test.ts", "packages/code-sdk/test/stream.test.ts" ] From fe912d25746a79c3b1d04bf99df96c18f343a0dc Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 19:19:19 +0800 Subject: [PATCH 05/10] fix(code-sdk): follow the official Windows installer launcher The installer and CLI self-update put a root mcode.cmd on PATH that reads the current release pointer and calls releases\\.mcode-launcher.cmd. Resolve that layout to the release's JS entry and keep running it with process.execPath, without a shell. The pointer is followed again before every spawn, so a self-update between Turns is used. When a launcher is installed but cannot be followed, ask for bin instead of suggesting an npm install, and stop mentioning a nonexistent mcode.exe. --- packages/code-sdk/src/cli-resolver.ts | 39 +++++++-- packages/code-sdk/src/errors.ts | 10 ++- packages/code-sdk/src/project-bin.ts | 56 +++++++++++- packages/code-sdk/test/cli-resolution.test.ts | 2 +- packages/code-sdk/test/project-bin.test.ts | 86 +++++++++++++++++++ 5 files changed, 178 insertions(+), 15 deletions(-) diff --git a/packages/code-sdk/src/cli-resolver.ts b/packages/code-sdk/src/cli-resolver.ts index 92bedb6d2..01719db7d 100644 --- a/packages/code-sdk/src/cli-resolver.ts +++ b/packages/code-sdk/src/cli-resolver.ts @@ -3,9 +3,9 @@ import { createRequire } from 'node:module'; import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { MCodeNotFoundError } from './errors.js'; -import { findProjectBin, parseCmdShim } from './project-bin.js'; +import { findProjectBin, resolveCmdLauncher, resolveVersionedLauncher } from './project-bin.js'; -export { parseCmdShim, parseShellShim } from './project-bin.js'; +export { parseCmdShim, parseShellShim, resolveVersionedLauncher } from './project-bin.js'; export const CLI_PACKAGE_NAME = '@minimax-ai/code'; @@ -14,6 +14,11 @@ export interface ResolvedCli { readonly kind: 'node' | 'exec'; /** JS entry run with `process.execPath` (`node`), or a native executable (`exec`). */ readonly path: string; + /** + * Versioned Windows root launcher behind `path`. Its current release is resolved again for + * every spawn, so a CLI self-update between Turns is picked up. + */ + readonly launcher?: string; readonly source: 'option' | 'package' | 'project' | 'path'; } @@ -119,20 +124,38 @@ function fromExecutable( return { kind: 'node', path, source }; } if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) { - const entry = parseCmdShim(readFileSync(path, 'utf8'), path); - if (entry) return { kind: 'node', path: entry, source }; + const resolved = resolveCmdLauncher(readFileSync(path, 'utf8'), path, source); + if (resolved) return resolved; // Running a batch file would need cmd.exe and shell quoting; ask for the JS entry instead. throw new MCodeNotFoundError( - `Cannot find the JS entry behind ${path}; pass the CLI's JS entry (or mcode.exe) as \`bin\`.`, + `Found ${path}, but could not determine the JS entry it runs. Pass the CLI's JS entry as \`bin\`.`, + { installHint: false }, ); } return { kind: 'exec', path, source }; } export function buildSpawnCommand(cli: ResolvedCli, args: readonly string[]): SpawnCommand { - return cli.kind === 'node' - ? { command: process.execPath, args: [cli.path, ...args] } - : { command: cli.path, args: [...args] }; + if (cli.kind === 'exec') return { command: cli.path, args: [...args] }; + return { command: process.execPath, args: [currentEntry(cli), ...args] }; +} + +/** The JS entry to run now; versioned launchers are followed again on every call. */ +function currentEntry(cli: ResolvedCli): string { + if (!cli.launcher) return cli.path; + let entry: string | undefined; + try { + entry = resolveVersionedLauncher(readFileSync(cli.launcher, 'utf8'), cli.launcher); + } catch { + entry = undefined; + } + if (!entry) { + throw new MCodeNotFoundError( + `${cli.launcher} no longer points to a usable release. Pass the CLI's JS entry as \`bin\`.`, + { installHint: false }, + ); + } + return entry; } function readEnv( diff --git a/packages/code-sdk/src/errors.ts b/packages/code-sdk/src/errors.ts index f54e47c66..8e173b594 100644 --- a/packages/code-sdk/src/errors.ts +++ b/packages/code-sdk/src/errors.ts @@ -111,8 +111,14 @@ export class MCodeProtocolError extends MCodeError { export class MCodeNotFoundError extends MCodeError { readonly installCommand = MCODE_INSTALL_HINT; - constructor(message: string, details?: MCodeErrorDetails) { - super(`${message} Install it with \`${MCODE_INSTALL_HINT}\` or pass \`bin\`.`, details); + /** `installHint: false` when a CLI is installed but cannot be used as found. */ + constructor(message: string, details: MCodeErrorDetails & { installHint?: boolean } = {}) { + super( + details.installHint === false + ? message + : `${message} Install it with \`${MCODE_INSTALL_HINT}\` or pass \`bin\`.`, + details, + ); this.name = 'MCodeNotFoundError'; } } diff --git a/packages/code-sdk/src/project-bin.ts b/packages/code-sdk/src/project-bin.ts index e07fb576a..11ebe97a6 100644 --- a/packages/code-sdk/src/project-bin.ts +++ b/packages/code-sdk/src/project-bin.ts @@ -1,9 +1,14 @@ -import { closeSync, openSync, readSync, realpathSync, statSync } from 'node:fs'; +import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs'; import { dirname, extname, join, resolve } from 'node:path'; import type { ResolvedCli } from './cli-resolver.js'; const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); +/** Lines of the Windows root launcher written by the official installer and CLI self-update. */ +const VERSIONED_POINTER_LINE = 'SET /P MCODE_RELEASE=<"%~dp0current"'; +const VERSIONED_CALL_LINE = 'CALL "%~dp0releases\\%MCODE_RELEASE%\\.mcode-launcher.cmd"'; +/** Same release-key rule the root launcher enforces before following the pointer. */ +const RELEASE_KEY = /^[0-9A-Za-z][0-9A-Za-z._-]*$/u; const SHIM_HEAD_BYTES = 4096; /** @@ -35,10 +40,12 @@ function resolveLauncher(launcher: string, platform: NodeJS.Platform): ResolvedC const extension = extname(launcher).toLowerCase(); if (platform === 'win32') { const head = readHead(launcher); - const entry = - extension === '.cmd' ? parseCmdShim(head, launcher) : parseShellShim(head, launcher); + if (extension !== '.cmd') { + const entry = parseShellShim(head, launcher); + return entry ? { kind: 'node', path: entry, source: 'project' } : undefined; + } // Shims are only used through their JS entry; the SDK never starts a shell. - return entry ? { kind: 'node', path: entry, source: 'project' } : undefined; + return resolveCmdLauncher(head, launcher, 'project'); } // npm links `.bin/mcode` to the package's JS entry; pnpm writes a shell shim. const real = realpathSync(launcher); @@ -53,6 +60,47 @@ function resolveLauncher(launcher: string, platform: NodeJS.Platform): ResolvedC return { kind: entry ? 'node' : 'exec', path: entry ?? launcher, source: 'project' }; } +/** + * Resolves a Windows `.cmd` launcher to the JS entry it runs: an npm / pnpm shim, or the + * versioned root launcher of the official installer. A versioned result keeps the root + * launcher so the current release is looked up again before every spawn. + */ +export function resolveCmdLauncher( + content: string, + launcherPath: string, + source: ResolvedCli['source'], +): ResolvedCli | undefined { + const shimEntry = parseCmdShim(content, launcherPath); + if (shimEntry) return { kind: 'node', path: shimEntry, source }; + const versionedEntry = resolveVersionedLauncher(content, launcherPath); + return versionedEntry + ? { kind: 'node', path: versionedEntry, source, launcher: launcherPath } + : undefined; +} + +/** + * Follows the installer's versioned layout: `\mcode.cmd` reads the release key from + * `\current` and calls `\releases\\.mcode-launcher.cmd`, which runs the + * CLI's JS entry. Returns that JS entry, or `undefined` when the layout does not match. + */ +export function resolveVersionedLauncher( + content: string, + launcherPath: string, +): string | undefined { + if (!content.includes(VERSIONED_POINTER_LINE) || !content.includes(VERSIONED_CALL_LINE)) { + return undefined; + } + const prefix = dirname(launcherPath); + try { + const release = readFileSync(join(prefix, 'current'), 'utf8').trim(); + if (!RELEASE_KEY.test(release)) return undefined; + const releaseLauncher = join(prefix, 'releases', release, '.mcode-launcher.cmd'); + return parseCmdShim(readFileSync(releaseLauncher, 'utf8'), releaseLauncher); + } catch { + return undefined; + } +} + /** * Extracts the JS entry from an npm / pnpm generated `.cmd` shim, so the CLI runs with * `process.execPath` instead of through cmd.exe. diff --git a/packages/code-sdk/test/cli-resolution.test.ts b/packages/code-sdk/test/cli-resolution.test.ts index 1e95025ad..0ab5a4ead 100644 --- a/packages/code-sdk/test/cli-resolution.test.ts +++ b/packages/code-sdk/test/cli-resolution.test.ts @@ -110,7 +110,7 @@ describe('CLI resolution', () => { // Without a resolvable JS entry the SDK asks for one instead of falling back to cmd.exe. expect(() => resolveCli({ bin: shim, env: {}, platform: 'win32' })).toThrow( - /Cannot find the JS entry behind .*mcode\.cmd/u, + /Found .*mcode\.cmd, but could not determine the JS entry it runs/u, ); expect( buildSpawnCommand({ kind: 'exec', path: 'C:\\bin\\mcode.exe', source: 'path' }, ['exec']), diff --git a/packages/code-sdk/test/project-bin.test.ts b/packages/code-sdk/test/project-bin.test.ts index 07446eff6..835b9e43e 100644 --- a/packages/code-sdk/test/project-bin.test.ts +++ b/packages/code-sdk/test/project-bin.test.ts @@ -12,6 +12,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { buildSpawnCommand, resolveCli } from '../src/cli-resolver.js'; +import { MCodeNotFoundError } from '../src/errors.js'; import { findProjectBin, parseCmdShim, parseShellShim } from '../src/project-bin.js'; import { FAKE_MCODE, tempDir } from './helpers.js'; @@ -115,3 +116,88 @@ describe('project-local node_modules/.bin/mcode', () => { expect(parseShellShim('exec "$basedir/native-mcode" "$@"', ps1)).toBeUndefined(); }); }); + +/** + * The Windows layout written by the official installer (install.ps1) and by CLI self-update + * (packages/tui/src/update/versioned-prefix.ts): a root launcher that reads `current` and + * calls the release launcher, which runs the CLI's JS entry with a pinned Node.js runtime. + */ +function windowsInstall( + releases: readonly string[], + current: string, +): { prefix: string; entry: (release: string) => string } { + const prefix = tempDir(); + const entry = (release: string): string => + join(prefix, 'releases', release, 'node_modules', '@minimax-ai', 'code', 'cli.js'); + for (const release of releases) { + mkdirSync(join(prefix, 'releases', release, 'node_modules', '@minimax-ai', 'code'), { + recursive: true, + }); + copyFileSync(FAKE_MCODE, entry(release)); + writeFileSync( + join(prefix, 'releases', release, '.mcode-launcher.cmd'), + '@ECHO off\r\n"C:\\Program Files\\nodejs\\node.exe" "%~dp0node_modules\\@minimax-ai\\code\\cli.js" %*\r\nEXIT /B %ERRORLEVEL%\r\n', + ); + } + writeFileSync( + join(prefix, 'mcode.cmd'), + '@ECHO off\r\nSETLOCAL\r\nSET /P MCODE_RELEASE=<"%~dp0current"\r\nECHO(%MCODE_RELEASE%| %SystemRoot%\\System32\\findstr.exe /R /X "[0-9A-Za-z][0-9A-Za-z._-]*" >NUL || EXIT /B 1\r\nCALL "%~dp0releases\\%MCODE_RELEASE%\\.mcode-launcher.cmd" %*\r\nEXIT /B %ERRORLEVEL%\r\n', + ); + writeFileSync(join(prefix, 'current'), `${current}\r\n`); + return { prefix, entry }; +} + +describe('official Windows installer layout', () => { + it('follows mcode.cmd → current → .mcode-launcher.cmd to the JS entry, without a shell', () => { + const { prefix, entry } = windowsInstall(['0.7.1'], '0.7.1'); + const options = { + packageSearchFrom: [], + projectSearchFrom: tempDir(), + platform: 'win32' as const, + }; + + const viaPath = resolveCli({ env: { PATH: prefix, PATHEXT: '.cmd' }, ...options }); + const viaBin = resolveCli({ bin: join(prefix, 'mcode.cmd'), env: {}, ...options }); + + for (const cli of [viaPath, viaBin]) { + expect(cli).toMatchObject({ + kind: 'node', + path: entry('0.7.1'), + launcher: join(prefix, 'mcode.cmd'), + }); + expect(buildSpawnCommand(cli, ['--version'])).toEqual({ + command: process.execPath, + args: [entry('0.7.1'), '--version'], + }); + } + }); + + it('re-reads the current release for every spawn, so a self-update is picked up', () => { + const { prefix, entry } = windowsInstall(['0.7.1', '0.7.2'], '0.7.1'); + const cli = resolveCli({ bin: join(prefix, 'mcode.cmd'), env: {}, platform: 'win32' }); + writeFileSync(join(prefix, 'current'), '0.7.2\r\n'); + + expect(buildSpawnCommand(cli, ['exec']).args[0]).toBe(entry('0.7.2')); + + writeFileSync(join(prefix, 'current'), '..\\outside\r\n'); + expect(() => buildSpawnCommand(cli, ['exec'])).toThrow(/no longer points to a usable release/u); + }); + + it('asks for bin, not an install, when an installed launcher cannot be followed', () => { + const { prefix } = windowsInstall(['0.7.1'], '../escape'); + const error = (() => { + try { + resolveCli({ bin: join(prefix, 'mcode.cmd'), env: {}, platform: 'win32' }); + } catch (caught) { + return caught as Error; + } + return undefined; + })(); + + expect(error).toBeInstanceOf(MCodeNotFoundError); + expect(error?.message).toMatch( + /could not determine the JS entry it runs\. Pass the CLI's JS entry as `bin`\.$/u, + ); + expect(error?.message).not.toMatch(/npm install|mcode\.exe/u); + }); +}); From 616ac3c0bce828d638e574606c101ef0a0239039 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 19:19:19 +0800 Subject: [PATCH 06/10] fix(scripts): scan the public text policy like any other source file The policy patterns never match their own definitions, so the check:source exemption for scripts/lib/public-text-policy.mjs only left a gap where an internal address added to that file went unreported. Remove it and add a regression test. --- scripts/source-inventory.mjs | 2 -- test/source-sync.test.mjs | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/source-inventory.mjs b/scripts/source-inventory.mjs index cc12b5b59..50f968a51 100644 --- a/scripts/source-inventory.mjs +++ b/scripts/source-inventory.mjs @@ -52,8 +52,6 @@ for (const file of files) { violations.push(`${file}: retired source`); if (/^(?:.*\/)?\.env(?:\.|$)/u.test(file) && !file.endsWith(".env.example")) violations.push(`${file}: environment file`); - // The policy module defines the patterns and would match itself. - if (file === "scripts/lib/public-text-policy.mjs") continue; let content = readFileSync(path.join(root, file)); if (file.endsWith(".gz")) content = gunzipSync(content); const text = content.toString("utf8"); diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index a4ee1e96a..1aede2116 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -9,6 +9,7 @@ import { extractionPath, inventoryPath } from '../scripts/lib/release-metadata.m import { extractSourceArchive } from '../scripts/lib/source-archive.mjs'; import { classifyChanges } from '../scripts/ci-changes.mjs'; import { suiteInventoryViolations } from '../scripts/lib/vitest-suites.mjs'; +import { embeddedCredential, internalSourceReference } from '../scripts/lib/public-text-policy.mjs'; import { Header } from 'tar'; import { gzipSync } from 'node:zlib'; import { createHash } from 'node:crypto'; @@ -973,6 +974,15 @@ test('Windows contract profile selects focused gates', () => { } }); +test('the public text policy is scanned by check:source like every other file', () => { + const read = file => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); + // The patterns never match their own definitions, so no file needs an exemption. + assert.equal(internalSourceReference.test(read('scripts/lib/public-text-policy.mjs')), false); + assert.equal(embeddedCredential.test(read('scripts/lib/public-text-policy.mjs')), false); + assert.doesNotMatch(read('scripts/source-inventory.mjs'), /public-text-policy\.mjs["'`]\)?\s*continue/u); + assert.doesNotMatch(read('scripts/source-inventory.mjs'), /if \(file === ["'`][^"'`]+["'`]\) continue/u); +}); + test('source inventory rejects unregistered, missing and duplicate first-party test gates', () => { const existing = 'packages/example/src/existing.test.ts'; const omitted = 'packages/example/test/omitted.spec.tsx'; From 46f8dbe479572672107016924057700ec2c96624 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 19:19:19 +0800 Subject: [PATCH 07/10] docs(code-sdk): correct the error mapping and package notes - README: map errors separately for exec results and for exits without a result (only exit 0 is a protocol error; unknown codes are internal run errors), describe require(esm) instead of a CommonJS export condition, and list the supported Windows installations. - CONTRIBUTING: state that this repository is the package's only source, and that both SDK gates run in the full and platform profiles. - Note that the public 0.7.0 CLI accepts the SDK's flags; test exit 1. --- packages/code-sdk/CONTRIBUTING.md | 12 +- packages/code-sdk/README.md | 143 ++++++++++++++-------- packages/code-sdk/src/version.ts | 2 +- packages/code-sdk/test/exit-codes.test.ts | 2 + 4 files changed, 103 insertions(+), 56 deletions(-) diff --git a/packages/code-sdk/CONTRIBUTING.md b/packages/code-sdk/CONTRIBUTING.md index 345c27dfd..1fa3c7473 100644 --- a/packages/code-sdk/CONTRIBUTING.md +++ b/packages/code-sdk/CONTRIBUTING.md @@ -1,5 +1,9 @@ # Contributing to `@minimax-ai/code-sdk` +This package is maintained only in this repository: this is its single source, and it is not +produced or updated by source synchronization. Change it here through pull requests, and keep it +out of synchronization candidates and conflict resolution. + This file is for repository contributors and is not part of the npm package (`files` only includes `dist`, `src`, `README.md` and `CHANGELOG.md`). Repository-wide rules are in the root [CONTRIBUTING.md](../../CONTRIBUTING.md) and [AGENTS.md](../../AGENTS.md); releases follow @@ -14,8 +18,8 @@ pnpm typecheck:code-sdk # SDK sources and tests pnpm --filter @minimax-ai/code-sdk run build ``` -Both gates are part of `pnpm verify`: `typecheck:code-sdk` in the full profile, `test:code-sdk` on -Linux and macOS. Add new test files to the `code-sdk` group in `test/vitest-suites.json`. +Both gates are part of `pnpm verify` on Linux and macOS, in the full and the platform profile. The +Windows contract profile does not run them. Add new test files to the `code-sdk` group in `test/vitest-suites.json`. ## Test doubles and contracts @@ -36,4 +40,6 @@ Linux and macOS. Add new test files to the `code-sdk` group in `test/vitest-suit `dist/`, `src/`, `README.md` and `CHANGELOG.md` are published verbatim. Keep repository paths, CI job names and contributor instructions out of them; put them here or in `RELEASING.md`. -`release:check` scans the packed tarball before every release. +`release:check` scans the packed tarball before every release. Its side-effect-free helpers +(registry pinning and tool invocation) live in `scripts/release-tools.mjs` and are unit tested in +`test/release-check.test.ts`. diff --git a/packages/code-sdk/README.md b/packages/code-sdk/README.md index f33d65c77..ab3ee037a 100644 --- a/packages/code-sdk/README.md +++ b/packages/code-sdk/README.md @@ -20,7 +20,9 @@ agents, or orchestrate multi-turn conversations from code. crashed host can resume a Thread from its id with `resumeThread`. - The SDK consumes only the public `mcode exec` contract (stdout JSONL plus exit code) and never reads runtime-internal data. -- No runtime dependencies; ESM with a `require()`-compatible export condition. +- No runtime dependencies. The package is ESM; its `exports` have `import` and `default` + conditions and no separate CommonJS build. CommonJS callers can still `require()` it, because + every Node.js version in the supported range loads ES modules through `require(esm)`. ## Installation and CLI resolution @@ -42,9 +44,18 @@ The SDK looks for the CLI in this order: `mcode.ps1` on Windows) found walking up from `process.cwd()`. The SDK prefers the JS entry behind the symlink or npm/pnpm shim and runs it with `process.execPath`, so the CLI package name does not matter. -4. `mcode` on `PATH`. On Windows, `mcode.cmd` is used through the JS entry behind its npm/pnpm - shim; the SDK never starts the CLI through a shell, so a `.cmd` without a recognizable JS entry - fails with `MCodeNotFoundError` and you should pass the JS entry (or `mcode.exe`) as `bin`. +4. `mcode` on `PATH`. + +The SDK never starts the CLI through a shell. On Windows, `mcode.cmd` is followed to the JS entry it +runs, which the SDK then starts with `process.execPath`. Supported Windows installations: + +- the official installer (`install.ps1`): `mcode.cmd` reads the `current` release pointer and calls + `releases\\.mcode-launcher.cmd`. The pointer is read again before every run, so a CLI + self-update between Turns is picked up; +- npm and pnpm installs, global or project-local, through their generated `.cmd` / `.ps1` shims. + +If a `.cmd` launcher is found but its JS entry cannot be determined, resolution fails with +`MCodeNotFoundError`; pass the CLI's JS entry as `bin`. Before the first run, each `MCode` instance runs `mcode --version` once and caches the result. A CLI older than `MIN_CLI_VERSION` (currently `0.7.0`) fails with `MCodeVersionError`. Only @@ -52,9 +63,9 @@ CLI older than `MIN_CLI_VERSION` (currently `0.7.0`) fails with `MCodeVersionErr ### Compatibility -| `@minimax-ai/code-sdk` | MiniMax Code CLI (`mcode`) | Node.js | Event `schemaVersion` | -| ---------------------- | ------------------------------ | ----------------------------- | --------------------- | -| `0.1.x` | `>=0.7.0` (`MIN_CLI_VERSION`) | `>=22.19 <23 \|\| >=24.2 <27` | `1` | +| `@minimax-ai/code-sdk` | MiniMax Code CLI (`mcode`) | Node.js | Event `schemaVersion` | +| ---------------------- | ----------------------------- | ----------------------------- | --------------------- | +| `0.1.x` | `>=0.7.0` (`MIN_CLI_VERSION`) | `>=22.19 <23 \|\| >=24.2 <27` | `1` | The SDK and the CLI are released separately. Upgrading the SDK does not require upgrading the CLI as long as the CLI meets `MIN_CLI_VERSION`. If the CLI ever emits a new event `schemaVersion`, @@ -63,39 +74,39 @@ upgrade to an SDK release that supports it. The Node.js range matches the CLI's. ## Quick start ```ts -import { MCode } from '@minimax-ai/code-sdk'; +import { MCode } from "@minimax-ai/code-sdk"; -const mcode = new MCode({ dataDir: '/srv/mcode-data' }); +const mcode = new MCode({ dataDir: "/srv/mcode-data" }); const thread = mcode.startThread({ - cwd: '/path/to/repo', - model: 'minimax/MiniMax-M3', - permission: 'smart', + cwd: "/path/to/repo", + model: "minimax/MiniMax-M3", + permission: "smart", }); -const turn = await thread.run('Diagnose and fix the failing CI job', { - files: ['./ci.log'], +const turn = await thread.run("Diagnose and fix the failing CI job", { + files: ["./ci.log"], timeoutMs: 10 * 60_000, }); console.log(thread.id, turn.finalResponse, turn.usage); // Continue the same Thread -await thread.run('Add unit tests for the fix'); +await thread.run("Add unit tests for the fix"); // Resume an earlier Session -const resumed = mcode.resumeThread('mvs_xxx', { cwd: '/path/to/repo' }); -await resumed.run('Continue where you left off'); +const resumed = mcode.resumeThread("mvs_xxx", { cwd: "/path/to/repo" }); +await resumed.run("Continue where you left off"); ``` Streaming: ```ts -const { events } = await thread.runStreamed('Implement the plan'); +const { events } = await thread.runStreamed("Implement the plan"); for await (const event of events) { switch (event.type) { - case 'item.completed': + case "item.completed": console.log(event.item); break; - case 'turn.failed': + case "turn.failed": console.error(event.status, event.error); break; default: @@ -109,34 +120,34 @@ for await (const event of events) { ### `new MCode(options?)` -| Option | Description | -| ------------ | ----------------------------------------------------------------------------------- | -| `bin` | Explicit `mcode` path or command name; skips package and `PATH` lookup | -| `dataDir` | Data directory, passed to the CLI as the `MINIMAX_DATA_DIR` environment variable | +| Option | Description | +| ------------ | ---------------------------------------------------------------------------------------------------- | +| `bin` | Explicit `mcode` path or command name; skips package and `PATH` lookup | +| `dataDir` | Data directory, passed to the CLI as the `MINIMAX_DATA_DIR` environment variable | | `env` | Child process environment. **Replaces** `process.env` when set (no merge); defaults to `process.env` | -| `configPath` | Runtime config file, passed as `--config` | +| `configPath` | Runtime config file, passed as `--config` | `env` replaces the environment entirely: passing only custom variables drops `PATH`, `HOME` and so on, so the CLI may fail to find Node.js or tools, or to read its credentials. To add variables, spread `process.env`: ```ts -const mcode = new MCode({ env: { ...process.env, FOO: 'bar' } }); +const mcode = new MCode({ env: { ...process.env, FOO: "bar" } }); ``` ### `startThread(options?)` / `resumeThread(id, options?)` Thread options are passed to the CLI on every Turn of the Thread: -| Option | CLI flag | -| -------------------- | ------------------------------------------------------------------------ | -| `cwd` | `--cwd` (defaults to the host process working directory) | -| `model` / `effort` | `--model` / `--effort` | -| `permission` | `--permission`: `smart` / `full` / `off` | +| Option | CLI flag | +| -------------------- | ------------------------------------------------------------------------------- | +| `cwd` | `--cwd` (defaults to the host process working directory) | +| `model` / `effort` | `--model` / `--effort` | +| `permission` | `--permission`: `smart` / `full` / `off` | | `mode` | `--mode`: `standard` / `lightweight`, only on the Turn that creates the Session | -| `promptMode` | `--prompt-mode`: `tui` / `coding` / `work` | -| `systemPrompt` | `--system-prompt-file` (the SDK writes a temporary file) | -| `appendSystemPrompt` | `--append-system-prompt-file` (the SDK writes a temporary file) | +| `promptMode` | `--prompt-mode`: `tui` / `coding` / `work` | +| `systemPrompt` | `--system-prompt-file` (the SDK writes a temporary file) | +| `appendSystemPrompt` | `--append-system-prompt-file` (the SDK writes a temporary file) | - `mode: 'lightweight'` is only valid for a new Thread; passing it to `resumeThread` throws `MCodeInvocationError` immediately. @@ -147,12 +158,12 @@ Thread options are passed to the CLI on every Turn of the Thread: ### `thread.run(input, options?)` / `thread.runStreamed(input, options?)` -| Option | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `files` | Attachments, `--file` (repeatable); relative paths resolve against `cwd` | -| `signal` | `AbortSignal`; see [Cancellation](#cancellation-timeouts-and-process-cleanup) | +| Option | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `files` | Attachments, `--file` (repeatable); relative paths resolve against `cwd` | +| `signal` | `AbortSignal`; see [Cancellation](#cancellation-timeouts-and-process-cleanup) | | `timeoutMs` | Passed to the CLI as `--timeout ms`; the SDK force-stops the CLI at `timeoutMs + 30s`. At most `MAX_TIMEOUT_MS` (about 24.8 days, a Node.js timer limit); larger values throw `MCodeInvocationError` | -| `maxSteps` | `--max-steps` | +| `maxSteps` | `--max-steps` | - `run()` resolves only when the status is `succeeded`, with a `Turn` (`finalResponse`, normalized `items`, `usage`, `model`, `sessionId`, `turnId`, `durationMs` and the full `result`). Every other @@ -195,17 +206,43 @@ protocol error. Every error extends `MCodeError` and carries `exitCode`, `signal` and the last 64 KiB of the CLI stderr (`stderr`, for diagnostics only). -| Source | Error | -| ---------------------------------------------------------- | ------------------------------------------------------------------ | -| Exit 2, or an option the SDK rejects | `MCodeInvocationError` | -| Status `failed` (exit 3 / 4 / 70) | `MCodeRunError` with `category` `config` / `runtime` / `internal` | -| Status `timeout` (exit 6) or the SDK timeout backstop | `MCodeRunError` with `status === 'timeout'` | -| Status `limit_exceeded` (exit 7) | `MCodeRunError` with `status === 'limit_exceeded'` | -| Status `cancelled` (exit 130) or SDK cancellation | `MCodeAbortError` with `name === 'AbortError'` | -| Exit 141, non-JSON stdout, or exit without `exec.completed` | `MCodeProtocolError` | -| No CLI found | `MCodeNotFoundError` (includes the install command) | -| CLI too old, or an unsupported event `schemaVersion` | `MCodeVersionError` | -| Concurrent Turn on the same Thread | `ThreadBusyError` | +When the CLI emits `exec.completed`, its `result.status` decides the error: + +| `result.status` | Error | +| ---------------- | ----------------------------------------------------------------------------------- | +| `failed` | `MCodeRunError` with `category` from the result (`config` / `runtime` / `internal`) | +| `timeout` | `MCodeRunError` with `status === 'timeout'` | +| `limit_exceeded` | `MCodeRunError` with `status === 'limit_exceeded'` | +| `cancelled` | `MCodeAbortError` with `name === 'AbortError'` | + +When the CLI exits without `exec.completed`, the exit code decides: + +| Exit code | Error | +| -------------------------------------- | ----------------------------------------------------------- | +| `2` (invalid invocation) | `MCodeInvocationError` | +| `3` | `MCodeRunError`, `status: 'failed'`, `category: 'config'` | +| `4` | `MCodeRunError`, `status: 'failed'`, `category: 'runtime'` | +| `6` | `MCodeRunError`, `status: 'timeout'` | +| `7` | `MCodeRunError`, `status: 'limit_exceeded'` | +| `70` | `MCodeRunError`, `status: 'failed'`, `category: 'internal'` | +| `130` | `MCodeAbortError` | +| `141` (broken pipe) | `MCodeProtocolError` | +| `0` (success without a result) | `MCodeProtocolError` | +| Any other code (e.g. `1`), or a signal | `MCodeRunError`, `status: 'failed'`, `category: 'internal'` | + +If the SDK itself cancelled the run (`signal`, or breaking out of `runStreamed()`), a missing +result is reported as `MCodeAbortError` unless the CLI exited with `2`. If the SDK force-stopped the +CLI after `timeoutMs + 30s`, it is `MCodeRunError` with `status === 'timeout'`. + +Errors raised by the SDK itself: + +| Source | Error | +| --------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| An option the SDK rejects before starting the CLI | `MCodeInvocationError` | +| Non-JSON stdout or an event missing required fields | `MCodeProtocolError` | +| No CLI found (includes the install command), or a found launcher whose JS entry cannot be determined (asks for `bin`) | `MCodeNotFoundError` | +| CLI older than `MIN_CLI_VERSION`, or an unsupported event `schemaVersion` | `MCodeVersionError` | +| Concurrent Turn on the same Thread | `ThreadBusyError` | `MCodeRunError` carries `result` (the full `ExecResult` when `exec.completed` was received), `items`, `retryable` (from `result.error.retryable`) and `sessionId`; `MCodeAbortError` also carries @@ -228,8 +265,10 @@ replaying writes with unknown outcomes; call `resumeThread(error.sessionId)` to ```ts const controller = new AbortController(); - process.once('SIGTERM', () => controller.abort()); - await thread.run(prompt, { signal: controller.signal }).catch(() => undefined); + process.once("SIGTERM", () => controller.abort()); + await thread + .run(prompt, { signal: controller.signal }) + .catch(() => undefined); ``` - After the CLI exits, the SDK keeps reading stdout / stderr for at most about two seconds. If a diff --git a/packages/code-sdk/src/version.ts b/packages/code-sdk/src/version.ts index 987bfbd0f..d61c2aae1 100644 --- a/packages/code-sdk/src/version.ts +++ b/packages/code-sdk/src/version.ts @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process'; import { buildSpawnCommand, type ResolvedCli } from './cli-resolver.js'; import { MCODE_INSTALL_HINT, MCodeNotFoundError, MCodeVersionError } from './errors.js'; -/** Minimum supported CLI version (provisional until verified on the public 0.7.x release). */ +/** Minimum supported CLI version; the public 0.7.0 release accepts every flag the SDK passes. */ export const MIN_CLI_VERSION = '0.7.0'; const VERSION_TIMEOUT_MS = 30_000; diff --git a/packages/code-sdk/test/exit-codes.test.ts b/packages/code-sdk/test/exit-codes.test.ts index 92f2a9f42..620c73cab 100644 --- a/packages/code-sdk/test/exit-codes.test.ts +++ b/packages/code-sdk/test/exit-codes.test.ts @@ -38,6 +38,8 @@ describe('exit code mapping without exec.completed', () => { [6, 'timeout', undefined], [7, 'limit_exceeded', undefined], [70, 'failed', 'internal'], + // Codes outside the documented exec contract. + [1, 'failed', 'internal'], ] as const)('maps exit %i to MCodeRunError(status=%s)', async (code, status, category) => { const error = await failureFor(code); expect(error).toBeInstanceOf(MCodeRunError); From 2179002d1f298eb048ebf34a2ba33bd00f1cb7fa Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 28 Oct 2026 19:50:54 +0800 Subject: [PATCH 08/10] docs(code-sdk): document npm allowScripts for project-local CLI installs npm 11 rejects --allow-scripts for project-scoped installs (EALLOWSCRIPTS), so a project that installs @minimax-ai/code next to the SDK must approve the CLI's install scripts in package.json. --- packages/code-sdk/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/code-sdk/README.md b/packages/code-sdk/README.md index ab3ee037a..d01b20442 100644 --- a/packages/code-sdk/README.md +++ b/packages/code-sdk/README.md @@ -34,6 +34,22 @@ npm install @minimax-ai/code-sdk npm install @minimax-ai/code # optional; a globally installed mcode works too ``` +`@minimax-ai/code` runs install scripts for its native dependencies. npm 11 rejects +`--allow-scripts` for project-scoped installs (`EALLOWSCRIPTS`), so approve them in the project's +`package.json` before installing the CLI into the project: + +```json +{ + "allowScripts": { + "@minimax-ai/code": true, + "better-sqlite3": true + } +} +``` + +Other package managers that gate install scripts need the equivalent approval for the same two +packages. + The SDK looks for the CLI in this order: 1. The `bin` constructor option (an executable, a JS entry, or a command name on `PATH`). From 2b865af583dbf1c7519ed7634cfa403090c667be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 06:40:31 +0000 Subject: [PATCH 09/10] fix(code-sdk): make cwd CLI lookup opt-in and address review Search process.cwd() for the CLI only when resolveFromCwd is set. Handle spawn failures without crashing the host, cancel with SIGTERM and the CLI's 60s shutdown budget, and accept CLI 0.6.3 so this repository's 0.6.5 build is supported. Release checks cover more local paths, refuse a directory publish, and omit npm publish off main. Co-authored-by: DanielWalnut --- docs/installation.md | 2 +- docs/open-source-status.md | 2 +- docs/releasing.md | 2 +- packages/code-sdk/CHANGELOG.md | 12 +- packages/code-sdk/README.md | 126 +++++++++++----- packages/code-sdk/RELEASING.md | 10 +- packages/code-sdk/package.json | 5 +- packages/code-sdk/scripts/release-check.mjs | 83 +++++----- packages/code-sdk/scripts/release-tools.mjs | 111 ++++++++++++++ packages/code-sdk/src/args.ts | 7 +- packages/code-sdk/src/cli-resolver.ts | 47 ++++-- packages/code-sdk/src/exec-process.ts | 142 +++++++++++++----- packages/code-sdk/src/mcode.ts | 1 + packages/code-sdk/src/thread.ts | 7 +- packages/code-sdk/src/types.ts | 14 +- packages/code-sdk/src/version.ts | 85 +++++++---- packages/code-sdk/test/args.test.ts | 10 +- packages/code-sdk/test/cli-contract.test.ts | 128 +++++++++++++++- packages/code-sdk/test/cli-resolution.test.ts | 46 +++++- packages/code-sdk/test/exit-codes.test.ts | 91 ++++++++++- .../test/fixtures/cli-package/mcode.mjs | 10 +- packages/code-sdk/test/helpers.ts | 1 + packages/code-sdk/test/project-bin.test.ts | 29 +++- packages/code-sdk/test/release-check.test.ts | 124 ++++++++++++++- packages/code-sdk/test/run.test.ts | 7 +- packages/code-sdk/test/stream.test.ts | 2 +- 26 files changed, 908 insertions(+), 196 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index eaa64b2af..673a975ee 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -37,7 +37,7 @@ For npm, use the command in the README: it explicitly selects the public registr `@latest` follows the stable npm dist-tag. For a reproducible CLI version, replace `@latest` with an exact published version such as `@0.6.9`, keeping the other options. Use Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26 for both npm and source installations. -This guide builds the 0.6.3 source preview. Workspace/local build manifests remain `private: true` to prevent accidental publishing. A source checkout may contain additional reviewed distribution changes; matching version strings alone do not establish byte-for-byte or build-provenance equivalence with the official npm tarball. Use the committed source revision and release receipt to identify a source build. +This guide builds the 0.6.3 source preview. Workspace and local-build manifests remain `private: true` to prevent accidental publishing, except `@minimax-ai/code-sdk`. That package is public and is published only as a checked tarball (`packages/code-sdk/RELEASING.md`); `prepublishOnly` rejects `npm publish` in the package directory. A source checkout may contain additional reviewed distribution changes; matching version strings alone do not establish byte-for-byte or build-provenance equivalence with the official npm tarball. Use the committed source revision and release receipt to identify a source build. For a source build, you need Git, Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26, and pnpm 9.12.0. Regular CI uses Node.js 24 across Linux and macOS. The weekly and manual compatibility matrix covers Node.js 22.19.0, 24.2.0, 25, and 26 on both platforms. Windows CI and source-candidate validation are temporarily paused while their checks are made reliable. Initial installation and build require access to public npm. diff --git a/docs/open-source-status.md b/docs/open-source-status.md index 783bb9be1..021f34d91 100644 --- a/docs/open-source-status.md +++ b/docs/open-source-status.md @@ -13,7 +13,7 @@ The current source target is **TUI 0.6.3**. This repository contains the termina | Embedded mcode-tools | 0.0.4, extracted from public `@minimax-ai/code@0.3.11` | `scripts/lib/mcode-tools-artifact.mjs` | | Historical live-service acceptance and demo | TUI 0.3.11, recorded 2026-09-11 | `docs/verification.md`, `docs/release-audit.md`, `docs/demo.md` | -The product, TUI, and root workspace use the same 0.6.3 version. The embedded tool has its own version. Workspace and local-build manifests remain `private: true` to prevent accidental npm publication. Matching version strings do not prove that this source tree reproduces the published npm tarball. +The product, TUI, and root workspace use the same 0.6.3 version. The embedded tool has its own version. Workspace and local-build manifests remain `private: true` to prevent accidental npm publication, except `@minimax-ai/code-sdk`, the TypeScript SDK. It is published separately, only from a tarball checked by `release:check`, and `prepublishOnly` rejects `npm publish` in that package directory (see `packages/code-sdk/RELEASING.md`). Matching version strings do not prove that this source tree reproduces the published npm tarball. ## Source boundary diff --git a/docs/releasing.md b/docs/releasing.md index c984f7365..7e7ca6ece 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -70,7 +70,7 @@ This workflow does not publish to the npm registry or change the official instal ## Source previews -The current source target is MiniMax Code 0.6.3. Workspace and local-build manifests remain `private: true` to prevent accidental npm publication. A source release, npm package, and installer are separate artifacts with separate verification. +The current source target is MiniMax Code 0.6.3. Workspace and local-build manifests remain `private: true` to prevent accidental npm publication, except `@minimax-ai/code-sdk`. That package is public. Publish it only as the tarball checked by `packages/code-sdk` `release:check` (see `packages/code-sdk/RELEASING.md`). Its `prepublishOnly` script rejects `npm publish` run in the package directory. A source release, npm package, and installer are separate artifacts with separate verification. ## Prepare a release diff --git a/packages/code-sdk/CHANGELOG.md b/packages/code-sdk/CHANGELOG.md index f8ed63d6b..0144d6988 100644 --- a/packages/code-sdk/CHANGELOG.md +++ b/packages/code-sdk/CHANGELOG.md @@ -12,13 +12,15 @@ First public release. - `MCode`, `startThread`, `resumeThread`, `thread.run()` and `thread.runStreamed()` drive `mcode exec --output-format stream-json`, one CLI process per Turn. -- CLI resolution: explicit `bin`, installed `@minimax-ai/code`, the nearest - `node_modules/.bin/mcode` above the working directory, then `mcode` on `PATH`. -- Requires MiniMax Code CLI `0.7.0` or later and Node.js `>=22.19 <23 || >=24.2 <27`. +- CLI resolution: explicit `bin`, `@minimax-ai/code` installed next to the SDK, then `mcode` on + `PATH`. `resolveFromCwd` (default off) also searches `process.cwd()` and parent + `node_modules/.bin/mcode` directories. +- Requires MiniMax Code CLI `0.6.3` or later (a source build of this repository is `0.6.5`) and + Node.js `>=22.19 <23 || >=24.2 <27`. - Typed errors for invocation, run, cancellation, protocol, missing CLI, version and busy Thread failures, mapped from the CLI exit codes. -- Cancellation through `AbortSignal` (SIGINT, then SIGTERM, then SIGKILL) and a `timeoutMs` - backstop. +- Cancellation through `AbortSignal` (`SIGTERM`, then `SIGKILL` after the CLI's 60 second shutdown + budget) and a `timeoutMs` backstop of `timeoutMs + 90s`. - ESM with a `require()`-compatible export condition; no runtime dependencies. ### Not yet available diff --git a/packages/code-sdk/README.md b/packages/code-sdk/README.md index d01b20442..dfc6e40d8 100644 --- a/packages/code-sdk/README.md +++ b/packages/code-sdk/README.md @@ -53,14 +53,23 @@ packages. The SDK looks for the CLI in this order: 1. The `bin` constructor option (an executable, a JS entry, or a command name on `PATH`). -2. An installed `@minimax-ai/code`: resolved first from the SDK's own location (where peer - dependencies are installed), then from `process.cwd()`. The SDK reads `bin.mcode` from its - `package.json` and runs that JS entry with `process.execPath`. -3. Any project-local MiniMax Code CLI: the nearest `node_modules/.bin/mcode` (`mcode.cmd` / - `mcode.ps1` on Windows) found walking up from `process.cwd()`. The SDK prefers the JS entry behind - the symlink or npm/pnpm shim and runs it with `process.execPath`, so the CLI package name does - not matter. -4. `mcode` on `PATH`. +2. An installed `@minimax-ai/code` resolved from the SDK's own location (where the optional peer + dependency is installed). The SDK reads `bin.mcode` from that `package.json` and runs the JS + entry with `process.execPath`. +3. `mcode` on `PATH`. + +Set `resolveFromCwd: true` to also search the host's working directory, after the SDK-local package +and before `PATH`: + +- `@minimax-ai/code` resolved from `process.cwd()/package.json`. +- The nearest `node_modules/.bin/mcode` (`mcode.cmd` / `mcode.ps1` on Windows) found walking up from + `process.cwd()`. The SDK prefers the JS entry behind the symlink or npm/pnpm shim and runs it with + `process.execPath`, so the CLI package name does not matter. + +Leave `resolveFromCwd` off (the default) when the working directory may be untrusted, for example a +CI job checking out a pull request. A checkout can contain `node_modules/.bin/mcode`, and the SDK +would execute it with the host environment. Pass `bin`, or install the CLI next to the SDK or on +`PATH`, instead. The SDK never starts the CLI through a shell. On Windows, `mcode.cmd` is followed to the JS entry it runs, which the SDK then starts with `process.execPath`. Supported Windows installations: @@ -74,14 +83,17 @@ If a `.cmd` launcher is found but its JS entry cannot be determined, resolution `MCodeNotFoundError`; pass the CLI's JS entry as `bin`. Before the first run, each `MCode` instance runs `mcode --version` once and caches the result. A -CLI older than `MIN_CLI_VERSION` (currently `0.7.0`) fails with `MCodeVersionError`. Only -`major.minor.patch` is compared, so `0.7.0-preview.N` satisfies `0.7.0`. +CLI older than `MIN_CLI_VERSION` (currently `0.6.3`) fails with `MCodeVersionError`. Only +`major.minor.patch` is compared, so `0.6.3-preview.N` satisfies `0.6.3`. `0.6.3` is the first CLI +release that includes every `mcode exec` flag the SDK passes (`--system-prompt-file`, +`--append-system-prompt-file` and `--mode` landed there; `stream-json` was already present). A +source build of this repository reports `0.6.5` and meets the minimum. ### Compatibility | `@minimax-ai/code-sdk` | MiniMax Code CLI (`mcode`) | Node.js | Event `schemaVersion` | | ---------------------- | ----------------------------- | ----------------------------- | --------------------- | -| `0.1.x` | `>=0.7.0` (`MIN_CLI_VERSION`) | `>=22.19 <23 \|\| >=24.2 <27` | `1` | +| `0.1.x` | `>=0.6.3` (`MIN_CLI_VERSION`) | `>=22.19 <23 \|\| >=24.2 <27` | `1` | The SDK and the CLI are released separately. Upgrading the SDK does not require upgrading the CLI as long as the CLI meets `MIN_CLI_VERSION`. If the CLI ever emits a new event `schemaVersion`, @@ -136,12 +148,13 @@ for await (const event of events) { ### `new MCode(options?)` -| Option | Description | -| ------------ | ---------------------------------------------------------------------------------------------------- | -| `bin` | Explicit `mcode` path or command name; skips package and `PATH` lookup | -| `dataDir` | Data directory, passed to the CLI as the `MINIMAX_DATA_DIR` environment variable | -| `env` | Child process environment. **Replaces** `process.env` when set (no merge); defaults to `process.env` | -| `configPath` | Runtime config file, passed as `--config` | +| Option | Description | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bin` | Explicit `mcode` path or command name; skips package and `PATH` lookup | +| `resolveFromCwd` | Also search `process.cwd()` for `@minimax-ai/code` and for `node_modules/.bin/mcode` in parent directories. Default `false`. See [CLI resolution](#installation-and-cli-resolution) | +| `dataDir` | Data directory, passed to the CLI as the `MINIMAX_DATA_DIR` environment variable | +| `env` | Child process environment. **Replaces** `process.env` when set (no merge); defaults to `process.env` | +| `configPath` | Runtime config file, passed as `--config` | `env` replaces the environment entirely: passing only custom variables drops `PATH`, `HOME` and so on, so the CLI may fail to find Node.js or tools, or to read its credentials. To add variables, @@ -178,7 +191,7 @@ Thread options are passed to the CLI on every Turn of the Thread: | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `files` | Attachments, `--file` (repeatable); relative paths resolve against `cwd` | | `signal` | `AbortSignal`; see [Cancellation](#cancellation-timeouts-and-process-cleanup) | -| `timeoutMs` | Passed to the CLI as `--timeout ms`; the SDK force-stops the CLI at `timeoutMs + 30s`. At most `MAX_TIMEOUT_MS` (about 24.8 days, a Node.js timer limit); larger values throw `MCodeInvocationError` | +| `timeoutMs` | Passed to the CLI as `--timeout ms`; the SDK force-stops the CLI at `timeoutMs + 90s` (60s CLI shutdown plus 30s startup slack). At most `MAX_TIMEOUT_MS` (about 24.8 days, a Node.js timer limit); larger values throw `MCodeInvocationError` | | `maxSteps` | `--max-steps` | - `run()` resolves only when the status is `succeeded`, with a `Turn` (`finalResponse`, normalized @@ -188,13 +201,14 @@ Thread options are passed to the CLI on every Turn of the Thread: throws only for process or protocol failures. Breaking out of the loop cancels the Turn. - A Thread runs one Turn at a time. A concurrent call throws `ThreadBusyError`; Turns are not queued. - After breaking out of the iterator, or after a protocol error, the SDK sends the CLI a cancel - signal. Until the process exits (about one second for a cooperating CLI, at most about 15 seconds: - SIGINT → 10s → SIGTERM → 5s → SIGKILL), the Thread stays busy and further `run()` / + signal. Until the process exits (about one second for a cooperating CLI, at most the CLI's 60 + second shutdown budget: SIGTERM, then SIGKILL), the Thread stays busy and further `run()` / `runStreamed()` calls throw `ThreadBusyError`. The `for await` loop you broke out of also waits for the process to exit. - The prompt only travels over stdin, and system prompts through temporary files (mode `0600`). - The files are removed before `run()` settles and before a `runStreamed()` iterator ends. argv - contains only SDK-generated flags, identifiers and paths. + The files are removed before `run()` settles and before a `runStreamed()` iterator ends. If the + host calls `process.exit()` during a Turn, the SDK's `exit` hook deletes those directories with + `rmSync`. argv contains only SDK-generated flags, identifiers and paths. ### Events and items @@ -211,8 +225,9 @@ protocol error. This applies to snapshots only, never to text deltas (`contentDelta`). - Items without an `item.completed` when the Turn ends are marked `incomplete: true` (for example, a tool that was running when the Turn was cancelled). -- `tool_call.toolCall` is an `@experimental` `RawToolCall`: `status` is an internal numeric enum and - `input` / `output` are tool-specific structures. Neither is covered by the compatibility promise. +- `tool_call.toolCall` is an `@experimental` `RawToolCall`: `status` is `string | number` (often an + internal numeric enum) and `input` / `output` are tool-specific structures. Neither is covered by + the compatibility promise. `usage.totalTokens = inputTokens + outputTokens` and **excludes** `cacheReadTokens` / `cacheWriteTokens`; `reasoningTokens` may overlap `outputTokens`. @@ -248,7 +263,7 @@ When the CLI exits without `exec.completed`, the exit code decides: If the SDK itself cancelled the run (`signal`, or breaking out of `runStreamed()`), a missing result is reported as `MCodeAbortError` unless the CLI exited with `2`. If the SDK force-stopped the -CLI after `timeoutMs + 30s`, it is `MCodeRunError` with `status === 'timeout'`. +CLI after `timeoutMs + 90s`, it is `MCodeRunError` with `status === 'timeout'`. Errors raised by the SDK itself: @@ -268,16 +283,28 @@ replaying writes with unknown outcomes; call `resumeThread(error.sessionId)` to ## Cancellation, timeouts and process cleanup -- When `signal` aborts, the SDK sends `SIGINT` and the CLI ends the Turn as `cancelled`. If the - process has not exited after 10 seconds it receives `SIGTERM`, and `SIGKILL` 5 seconds later. -- `timeoutMs` is enforced by the CLI, which ends the run gracefully with status `timeout`; the SDK - only force-stops it at `timeoutMs + 30s`. +- When `signal` aborts, the SDK sends `SIGTERM`. The CLI treats `SIGTERM` the same way as `SIGINT`: + it cancels the Turn, shuts the runtime down (budget 60 seconds), then writes `exec.completed` + with status `cancelled`. If the process is still running after that budget, the SDK sends + `SIGKILL`. It does not send `SIGINT`. +- **Ctrl+C.** The CLI stays in the host's process group, so Ctrl+C delivers `SIGINT` to the host and + the CLI together. That `SIGINT` already starts the CLI's cancel path and consumes its one-shot + `SIGINT` handler. If the host also aborts the `AbortSignal` from its own `SIGINT` handler, the + SDK follows with `SIGTERM`, which the CLI still handles as a cancel (`SIGTERM` is a separate + handler). A second `SIGINT` would take the default action and kill the CLI before it writes + `exec.completed`. The README example below aborts on `SIGTERM`, which does not collide with + Ctrl+C. +- `timeoutMs` is enforced by the CLI. The CLI starts that timer after its access check, session + resolution and model selection, then ends the run with status `timeout` and may spend the same 60 + second shutdown budget before writing the result. The SDK's timer starts at spawn and force-stops + the process at `timeoutMs + 90s` (60s shutdown plus 30s of startup slack). - **Only normal host exits are covered**: when the host emits `exit` (the event loop drains or `process.exit()` is called), the SDK sends `SIGTERM` to any running CLI process, which the CLI - treats as a cancel. A host killed by a signal such as `SIGTERM` or `SIGINT` does not emit `exit`, - and its CLI processes keep running as orphans. The SDK installs no signal handlers and Phase 1 has - no `close()` / `dispose()`. Hosts that need reliable cleanup should abort in-flight runs from their - own signal handlers and wait for them, for example: + treats as a cancel, and deletes temporary prompt directories with `rmSync`. A host killed by a + signal such as `SIGTERM` or `SIGINT` does not emit `exit`, and its CLI processes keep running as + orphans. The SDK installs no signal handlers and Phase 1 has no `close()` / `dispose()`. Hosts + that need reliable cleanup should abort in-flight runs from their own signal handlers and wait + for them, for example: ```ts const controller = new AbortController(); @@ -294,9 +321,9 @@ replaying writes with unknown outcomes; call `resumeThread(error.sessionId)` to > [!WARNING] > -> **Windows**: Node.js cannot send a catchable `SIGINT` to a child process on Windows, so -> cancellation is a hard kill and the runtime may leave the Turn unfinished. Phase 1 only supports -> the hard kill; graceful cancellation needs a signal-independent cancel channel in the CLI. +> **Windows**: Node.js cannot deliver a catchable signal to a child process, so `SIGTERM` is a hard +> kill and the runtime may leave the Turn unfinished. Phase 1 only supports the hard kill; graceful +> cancellation needs a signal-independent cancel channel in the CLI. ## Concurrency and data directories @@ -309,3 +336,30 @@ directory beforehand. - `outputSchema` (MiniMax-hosted models do not reliably follow the schema yet). - Approval callbacks, steering a running Turn, continuing after an interrupt, and forking. - A Python SDK. + +## 中文 + +SDK 在 Node.js 里驱动本机 MiniMax Code(`mcode`)。下面只覆盖本次行为;完整说明见上文英文。 + +### CLI 查找 + +默认顺序: + +1. 构造参数 `bin`(可执行文件、JS 入口,或 `PATH` 上的命令名)。 +2. 安装在 SDK 旁边的 `@minimax-ai/code`(可选 peer dependency)。SDK 读取其 `bin.mcode`,并用 `process.execPath` 运行该 JS 入口。 +3. `PATH` 上的 `mcode`。 + +`resolveFromCwd` 默认为 `false`。设为 `true` 后,还会在 SDK 本地安装之后、`PATH` 之前搜索宿主的工作目录:先从 `process.cwd()/package.json` 解析 `@minimax-ai/code`,再从 `process.cwd()` 向上查找最近的 `node_modules/.bin/mcode`(Windows 上为 `mcode.cmd` / `mcode.ps1`)。工作目录不可信时(例如 CI 检出的拉取请求)请保持关闭:检出内容可以自带 `node_modules/.bin/mcode`,SDK 会带着宿主环境执行它。此时应传入 `bin`,或把 CLI 装在 SDK 旁边 / `PATH` 上。 + +SDK 从不通过 shell 启动 CLI。要求的最低 CLI 版本是 `0.6.3`(`MIN_CLI_VERSION`):该版本起具备 SDK 传入的全部 `mcode exec` 参数。本仓库源码构建的 CLI 是 `0.6.5`,满足该下限。只比较 `major.minor.patch`,因此 `0.6.3-preview.N` 视为满足 `0.6.3`。 + +### 取消、超时和退出 + +- `AbortSignal` 中止时,SDK 发送 `SIGTERM`。CLI 把 `SIGTERM` 与 `SIGINT` 同样视为取消:结束本轮、在 60 秒运行时关闭预算内收尾,然后写出 `exec.completed`(`cancelled`)。超过该预算进程仍在,SDK 才发送 `SIGKILL`。SDK 不发送 `SIGINT`。 +- 终端里的 Ctrl+C 会把 `SIGINT` 发给整个进程组,CLI 自己的一次性 `SIGINT` 处理函数会被用掉。若宿主也因此中止 `AbortSignal`,SDK 随后发送的 `SIGTERM` 仍走 CLI 的取消路径。再发一次 `SIGINT` 会直接杀死 CLI,来不及写出 `exec.completed`。 +- `timeoutMs` 由 CLI 在访问检查、会话解析和模型选择之后开始计时。SDK 从进程启动起计时,在 `timeoutMs + 90s`(60 秒关闭预算加 30 秒启动余量)时强制结束。 +- 宿主正常退出(事件循环排空或调用 `process.exit()`)时,SDK 向仍在运行的 CLI 发送 `SIGTERM`,并用 `rmSync` 删除临时 system prompt 目录。宿主被 `SIGTERM` / `SIGINT` 杀死时不会发出 `exit`,子进程会留下。Windows 上 `SIGTERM` 是硬终止,无法优雅取消。 + +### 发布 + +`@minimax-ai/code-sdk` 是本仓库里第一个非 `private` 的工作区包。不要在包目录里直接 `npm publish`:`prepublishOnly` 会拒绝。在最新的 `main` 上运行 `pnpm --filter @minimax-ai/code-sdk run release:check`,再发布它打印出的、已经检查过的 tarball。见 `RELEASING.md`。 diff --git a/packages/code-sdk/RELEASING.md b/packages/code-sdk/RELEASING.md index aa94a1df3..573a49e53 100644 --- a/packages/code-sdk/RELEASING.md +++ b/packages/code-sdk/RELEASING.md @@ -46,8 +46,9 @@ pnpm --filter @minimax-ai/code-sdk run release:check only `dist`, `src`, `README.md` and `CHANGELOG.md` (npm adds `package.json` and `LICENSE`); 4. builds, type-checks and runs the SDK test suite; 5. runs `npm pack`, rejects unexpected tarball entries, and scans every packed file for internal - host names, credentials, local user paths (`/Users/…`), other repository package paths and - internal package names; + host names, credentials, local user paths (`/Users/…`, `/home//`, `/root/`, + `/private/var/folders/…`, `C:\Users\…` and the escaped `C:\\Users\\…` form), other repository + package paths and internal package names; 6. installs the tarball into an empty project and checks both `import` and `require`; 7. prints the exact `npm publish` command for the checked tarball and the read-back commands. @@ -60,6 +61,11 @@ the pnpm script that started it), which has not been exercised on a Windows mach ## 3. Publish +Do not run `npm publish` in `packages/code-sdk`. That package is the first non-private workspace +package, and its `prepublishOnly` script refuses a directory publish so a fresh checkout cannot +skip `release:check` and upload a package without `dist`. Publishing the checked tarball does not +run that script. + Run the printed command yourself. It publishes the tarball that was just checked: ```bash diff --git a/packages/code-sdk/package.json b/packages/code-sdk/package.json index 91b93bff5..eae33a62c 100644 --- a/packages/code-sdk/package.json +++ b/packages/code-sdk/package.json @@ -46,7 +46,7 @@ "node": ">=22.19 <23 || >=24.2 <27" }, "peerDependencies": { - "@minimax-ai/code": ">=0.7.0" + "@minimax-ai/code": ">=0.6.3" }, "peerDependenciesMeta": { "@minimax-ai/code": { @@ -58,7 +58,8 @@ "build": "pnpm run clean:build && tsc --build --force", "typecheck": "tsc --noEmit -p tsconfig.check.json", "test": "node ../../scripts/run-vitest-suite.mjs code-sdk", - "release:check": "node scripts/release-check.mjs" + "release:check": "node scripts/release-check.mjs", + "prepublishOnly": "node scripts/release-tools.mjs" }, "dependencies": {}, "devDependencies": { diff --git a/packages/code-sdk/scripts/release-check.mjs b/packages/code-sdk/scripts/release-check.mjs index 736d17984..77a030b46 100644 --- a/packages/code-sdk/scripts/release-check.mjs +++ b/packages/code-sdk/scripts/release-check.mjs @@ -22,44 +22,34 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { + isReleaseRehearsal, NPMJS_REGISTRY, NPMJS_TARGET_ARGS, normalizeRegistry, PACKAGE_SCOPE, - publishCommand, - readBackCommands, + PACKAGE_TEXT_RULES, + releaseClosingLines, resolveTool, scopedRegistryProblem, + tarballProblems, + textLabels, } from './release-tools.mjs'; const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(packageDir, '../..'); -// The repository's own publication rules (also used by `check:source`); this script runs only -// inside the repository checkout, never from the published package. +// Shared with `check:source`. Dynamic import keeps this repository file out of the SDK typecheck +// root. The script runs only inside the checkout, never from the published package. const { embeddedCredential, internalSourceReference } = await import( pathToFileURL(path.join(repoRoot, 'scripts/lib/public-text-policy.mjs')).href ); -const REGISTRY = NPMJS_REGISTRY; -const RELEASE_BRANCH = 'main'; -const PUBLISHED_FILES = ['dist', 'src', 'README.md', 'CHANGELOG.md']; -const ALLOWED_ENTRY = - /^package\/(?:package\.json|README\.md|CHANGELOG\.md|LICENSE|(?:dist|src)\/[^/]+\.(?:js|js\.map|d\.ts|d\.ts\.map|ts))$/u; -const REQUIRED_ENTRIES = [ - 'package/package.json', - 'package/README.md', - 'package/CHANGELOG.md', - 'package/LICENSE', - 'package/dist/index.js', - 'package/dist/index.d.ts', -]; -/** Published text must not point into the repository, at a local machine, or at private names. */ -const PUBLIC_TEXT_RULES = [ +const textRules = [ ['internal source reference', internalSourceReference], ['embedded credential', embeddedCredential], - ['local user path', /(?:\/Users\/|[A-Za-z]:\\Users\\)/u], - ['repository package path', /\b(?:packages\/(?!code-sdk\b)[\w.-]+|third_party)\//u], - ['internal package name', /@minimax\/code\b|@mavis\//u], + ...PACKAGE_TEXT_RULES, ]; +const REGISTRY = NPMJS_REGISTRY; +const RELEASE_BRANCH = 'main'; +const PUBLISHED_FILES = ['dist', 'src', 'README.md', 'CHANGELOG.md']; const args = new Set(process.argv.slice(2).filter((arg) => arg !== '--')); for (const arg of args) { @@ -99,10 +89,6 @@ function run(tool, commandArgs, { cwd = repoRoot, capture = false, allowFailure return result; } -const shellWord = (value) => - /^[\w@%+=:,./-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; -const printable = (command) => command.map(shellWord).join(' '); - function git(commandArgs) { return run('git', commandArgs, { capture: true }).stdout.trim(); } @@ -200,20 +186,21 @@ const tarball = path.join(outDir, packed.filename); const entries = run('tar', ['-tzf', tarball], { capture: true }) .stdout.split(/\r?\n/u) .filter((entry) => entry && !entry.endsWith('/')); -const unexpected = entries.filter( - (entry) => !ALLOWED_ENTRY.test(entry) || entry.split('/').includes('..'), -); -if (unexpected.length) fail(`Unexpected tarball entries: ${unexpected.join(', ')}`); -const missing = REQUIRED_ENTRIES.filter((entry) => !entries.includes(entry)); -if (missing.length) fail(`Missing tarball entries: ${missing.join(', ')}`); +const packedEntries = tarballProblems(entries); +if (packedEntries.unexpected.length) { + fail(`Unexpected tarball entries: ${packedEntries.unexpected.join(', ')}`); +} +if (packedEntries.missing.length) { + fail(`Missing tarball entries: ${packedEntries.missing.join(', ')}`); +} const extracted = path.join(outDir, 'extracted'); mkdirSync(extracted); run('tar', ['-xzf', tarball, '-C', extracted]); const findings = []; for (const file of listFiles(path.join(extracted, 'package'))) { const text = readFileSync(file, 'utf8'); - for (const [label, pattern] of PUBLIC_TEXT_RULES) { - if (pattern.test(text)) findings.push(`${path.relative(extracted, file)}: ${label}`); + for (const label of textLabels(text, textRules)) { + findings.push(`${path.relative(extracted, file)}: ${label}`); } } if (findings.length) @@ -259,16 +246,20 @@ const smoke = run( ); console.log(`✓ ${smoke.stdout.trim()}`); -const rehearsal = branch !== RELEASE_BRANCH || head !== remoteMain; -console.log( - rehearsal - ? `\n! Rehearsal passed for ${spec}. Do not publish this tarball: release from an up-to-date ${RELEASE_BRANCH}.` - : `\n✓ ${spec} is ready. The tarball was checked but nothing was published.`, -); -if (!rehearsal) { - console.log('\nPublish it yourself (npm login with 2FA required):\n'); - console.log(` ${printable(publishCommand(tarball))}`); - const [viewVersion, viewTags] = readBackCommands(spec, name); - console.log(`\nThen read it back:\n\n ${printable(viewVersion)} # expect ${integrity}`); - console.log(` ${printable(viewTags)} # expect latest: ${version}`); +const rehearsal = isReleaseRehearsal({ + branch, + head, + remoteMain, + releaseBranch: RELEASE_BRANCH, +}); +for (const line of releaseClosingLines({ + rehearsal, + spec, + releaseBranch: RELEASE_BRANCH, + tarball, + integrity, + version, + name, +})) { + console.log(line); } diff --git a/packages/code-sdk/scripts/release-tools.mjs b/packages/code-sdk/scripts/release-tools.mjs index 124420d1b..affd92db7 100644 --- a/packages/code-sdk/scripts/release-tools.mjs +++ b/packages/code-sdk/scripts/release-tools.mjs @@ -1,8 +1,10 @@ /** * Helpers for release-check.mjs, kept free of side effects so they can be unit tested. + * Run directly, this file is the `prepublishOnly` guard and always refuses a directory publish. */ import { existsSync } from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; export const NPMJS_REGISTRY = 'https://registry.npmjs.org/'; export const PACKAGE_SCOPE = '@minimax-ai'; @@ -98,3 +100,112 @@ export function resolveTool( } throw new Error(`Unsupported tool ${name}`); } + +/** + * Local paths that must not appear in a published tarball. Covers macOS and Linux home + * directories, root's home, macOS per-user temp folders, and both the single-backslash and + * the escaped `C:\\Users\\` forms that show up in JS strings, JSON and source maps. + */ +export const LOCAL_USER_PATH = + /(?:\/Users\/|\/home\/[^/\s]+\/|\/root\/|\/private\/var\/folders\/|[A-Za-z]:\\Users\\|[A-Za-z]:\\\\Users\\\\)/u; + +/** Package-local scan rules. Shared credential and host patterns are added by release-check. */ +export const PACKAGE_TEXT_RULES = Object.freeze([ + ['local user path', LOCAL_USER_PATH], + ['repository package path', /\b(?:packages\/(?!code-sdk\b)[\w.-]+|third_party)\//u], + ['internal package name', /@minimax\/code\b|@mavis\//u], +]); + +/** npm pack paths, relative to the tarball root. One directory level under dist/ and src/. */ +export const TARBALL_ENTRY = + /^package\/(?:package\.json|README\.md|CHANGELOG\.md|LICENSE|(?:dist|src)\/[^/]+\.(?:js|js\.map|d\.ts|d\.ts\.map|ts))$/u; + +export const REQUIRED_TARBALL_ENTRIES = Object.freeze([ + 'package/package.json', + 'package/README.md', + 'package/CHANGELOG.md', + 'package/LICENSE', + 'package/dist/index.js', + 'package/dist/index.d.ts', +]); + +export const DIRECTORY_PUBLISH_MESSAGE = [ + 'Refusing to publish @minimax-ai/code-sdk from the package directory.', + 'Run `pnpm --filter @minimax-ai/code-sdk run release:check` on an up-to-date main and publish the checked tarball that command prints.', + '`npm publish ` does not run this script. `npm publish` in this directory does, and it would skip release:check.', +].join('\n'); + +/** Rule labels from `rules` that match `text`, if any. */ +export function textLabels(text, rules) { + return rules + .filter(([, pattern]) => { + pattern.lastIndex = 0; + return pattern.test(text); + }) + .map(([label]) => label); +} + +/** Allowlist result for `npm pack` entry names (directories omitted). */ +export function tarballProblems(entries) { + const files = entries.filter((entry) => entry && !entry.endsWith('/')); + return { + unexpected: files.filter( + (entry) => !TARBALL_ENTRY.test(entry) || entry.split('/').includes('..'), + ), + missing: REQUIRED_TARBALL_ENTRIES.filter((entry) => !files.includes(entry)), + }; +} + +/** A shell word for the command printed to a maintainer. Not used to spawn a process. */ +export function shellWord(value) { + return /^[\w@%+=:,./-]+$/u.test(value) ? value : `'${String(value).replaceAll("'", "'\\''")}'`; +} + +export function formatCommand(command) { + return command.map(shellWord).join(' '); +} + +/** True when release:check must not print an `npm publish` command. */ +export function isReleaseRehearsal({ branch, head, remoteMain, releaseBranch = 'main' }) { + return branch !== releaseBranch || head !== remoteMain; +} + +/** + * Closing lines of release:check. A rehearsal (any branch other than an up-to-date main) does + * not include a publish command. + */ +export function releaseClosingLines({ + rehearsal, + spec, + releaseBranch, + tarball, + integrity, + version, + name, +}) { + const lines = [ + rehearsal + ? `\n! Rehearsal passed for ${spec}. Do not publish this tarball: release from an up-to-date ${releaseBranch}.` + : `\n✓ ${spec} is ready. The tarball was checked but nothing was published.`, + ]; + if (rehearsal) return lines; + const [viewVersion, viewTags] = readBackCommands(spec, name); + lines.push( + '\nPublish it yourself (npm login with 2FA required):\n', + ` ${formatCommand(publishCommand(tarball))}`, + `\nThen read it back:\n\n ${formatCommand(viewVersion)} # expect ${integrity}`, + ` ${formatCommand(viewTags)} # expect latest: ${version}`, + ); + return lines; +} + +function isDirectRun() { + const entry = process.argv[1]; + if (!entry) return false; + return import.meta.url === pathToFileURL(path.resolve(entry)).href; +} + +if (isDirectRun()) { + console.error(DIRECTORY_PUBLISH_MESSAGE); + process.exit(1); +} diff --git a/packages/code-sdk/src/args.ts b/packages/code-sdk/src/args.ts index bede909b7..647c014cc 100644 --- a/packages/code-sdk/src/args.ts +++ b/packages/code-sdk/src/args.ts @@ -1,5 +1,6 @@ import { resolve } from 'node:path'; +import { processTimings } from './exec-process.js'; import { MCodeInvocationError } from './errors.js'; import type { RunOptions, ThreadOptions } from './types.js'; @@ -74,10 +75,10 @@ export function validateThreadOptions(options: ThreadOptions, resuming: boolean) } /** - * Largest accepted `timeoutMs`: the SDK backstop timer (`timeoutMs + 30s`) must fit in a Node - * timer, whose delay is a signed 32-bit integer (about 24.8 days). + * Largest accepted `timeoutMs`: the SDK backstop timer (`timeoutMs + backstopExtraMs`) must fit + * in a Node timer, whose delay is a signed 32-bit integer (about 24.8 days). */ -export const MAX_TIMEOUT_MS = 2 ** 31 - 1 - 30_000; +export const MAX_TIMEOUT_MS = 2 ** 31 - 1 - processTimings.backstopExtraMs; export function validateRunOptions(options: RunOptions): void { if ( diff --git a/packages/code-sdk/src/cli-resolver.ts b/packages/code-sdk/src/cli-resolver.ts index 01719db7d..7eb7143d8 100644 --- a/packages/code-sdk/src/cli-resolver.ts +++ b/packages/code-sdk/src/cli-resolver.ts @@ -30,16 +30,28 @@ export interface SpawnCommand { export interface ResolveCliOptions { readonly bin?: string; readonly env: Readonly>; + /** + * Also search `cwd` (default `process.cwd()`) for `@minimax-ai/code` and for + * `node_modules/.bin/mcode` above that directory. Off unless set: a working directory can + * belong to an untrusted checkout. + */ + readonly resolveFromCwd?: boolean; + /** Directory used by {@link ResolveCliOptions.resolveFromCwd}. Defaults to `process.cwd()`. */ + readonly cwd?: string; /** Base paths for resolving the optional `@minimax-ai/code` peer dependency. */ readonly packageSearchFrom?: readonly string[]; - /** Directory where the upward `node_modules/.bin/mcode` search starts. */ + /** + * Directory where the upward `node_modules/.bin/mcode` search starts. Set this to search even + * when `resolveFromCwd` is off (tests and callers that already chose a directory). + */ readonly projectSearchFrom?: string; readonly platform?: NodeJS.Platform; } /** - * Resolution order: explicit `bin` → installed `@minimax-ai/code` package → nearest - * `node_modules/.bin/mcode` above the working directory → `mcode` on PATH. + * Resolution order: explicit `bin` → `@minimax-ai/code` installed next to the SDK → + * (only when `resolveFromCwd` is set) the same package from `cwd` and the nearest + * `node_modules/.bin/mcode` above `cwd` → `mcode` on PATH. */ export function resolveCli(options: ResolveCliOptions): ResolvedCli { const platform = options.platform ?? process.platform; @@ -54,19 +66,36 @@ export function resolveCli(options: ResolveCliOptions): ResolvedCli { throw new MCodeNotFoundError(`The configured mcode bin was not found: ${options.bin}.`); return fromExecutable(found, 'option', platform); } - const fromPackage = resolvePackageEntry( - options.packageSearchFrom ?? [import.meta.url, join(process.cwd(), 'package.json')], - ); + const fromPackage = resolvePackageEntry(packageSearchBases(options)); if (fromPackage) return { kind: 'node', path: fromPackage, source: 'package' }; - const fromProject = findProjectBin(options.projectSearchFrom ?? process.cwd(), platform); - if (fromProject) return fromProject; + const projectStart = projectSearchStart(options); + if (projectStart !== undefined) { + const fromProject = findProjectBin(projectStart, platform); + if (fromProject) return fromProject; + } const onPath = findOnPath('mcode', options.env, platform); if (onPath) return fromExecutable(onPath, 'path', platform); + const searchedCwd = options.resolveFromCwd === true || options.projectSearchFrom !== undefined; throw new MCodeNotFoundError( - `Could not find the mcode CLI (${CLI_PACKAGE_NAME} is not installed, no node_modules/.bin/mcode was found above the working directory, and mcode is not on PATH).`, + searchedCwd + ? `Could not find the mcode CLI (${CLI_PACKAGE_NAME} is not installed, no node_modules/.bin/mcode was found above the working directory, and mcode is not on PATH).` + : `Could not find the mcode CLI (${CLI_PACKAGE_NAME} is not installed next to the SDK, and mcode is not on PATH). Set \`resolveFromCwd\` to also search the working directory, or pass \`bin\`.`, ); } +function packageSearchBases(options: ResolveCliOptions): readonly string[] { + if (options.packageSearchFrom) return options.packageSearchFrom; + const bases = [import.meta.url]; + if (options.resolveFromCwd) bases.push(join(options.cwd ?? process.cwd(), 'package.json')); + return bases; +} + +function projectSearchStart(options: ResolveCliOptions): string | undefined { + if (options.projectSearchFrom !== undefined) return options.projectSearchFrom; + if (options.resolveFromCwd) return options.cwd ?? process.cwd(); + return undefined; +} + /** Resolves `bin.mcode` of an installed `@minimax-ai/code` package to an existing JS file. */ export function resolvePackageEntry(searchFrom: readonly string[]): string | undefined { for (const base of searchFrom) { diff --git a/packages/code-sdk/src/exec-process.ts b/packages/code-sdk/src/exec-process.ts index 4fb9577e7..c110f09d9 100644 --- a/packages/code-sdk/src/exec-process.ts +++ b/packages/code-sdk/src/exec-process.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from 'node:child_process'; +import { rmSync } from 'node:fs'; import { createInterface } from 'node:readline'; import type { SpawnCommand } from './cli-resolver.js'; @@ -14,14 +15,28 @@ import { import { parseEventLine } from './protocol.js'; import type { ThreadEvent, ThreadItem } from './types.js'; +/** + * CLI runtime shutdown budget (`DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS` in + * `packages/tui/src/runtime/lifecycle.ts`). Cancel and the timeout backstop wait at least this + * long so a CLI that is already shutting down can still write `exec.completed`. + */ +export const CLI_SHUTDOWN_BUDGET_MS = 60_000; + +/** Slack for work the CLI does before it starts its own `timeoutMs` timer. */ +export const CLI_STARTUP_SLACK_MS = 30_000; + /** Cancellation and cleanup timings. Mutable only so tests can shorten them. */ export const processTimings = { - /** SIGINT → SIGTERM. */ - interruptGraceMs: 10_000, - /** SIGTERM → SIGKILL. */ + /** SIGTERM → SIGKILL. Covers {@link CLI_SHUTDOWN_BUDGET_MS}. */ + cancelGraceMs: CLI_SHUTDOWN_BUDGET_MS, + /** After `exec.completed`, how long a lingering CLI may take to exit before SIGKILL. */ terminateGraceMs: 5_000, - /** Extra time after `timeoutMs` before the SDK force-kills a CLI that did not stop itself. */ - backstopExtraMs: 30_000, + /** + * Extra time after `timeoutMs` before the SDK force-kills a CLI that did not stop itself. + * Shutdown budget plus startup slack, because the SDK timer starts at spawn and the CLI timer + * starts only after access, session and model resolution. + */ + backstopExtraMs: CLI_SHUTDOWN_BUDGET_MS + CLI_STARTUP_SLACK_MS, /** * After the CLI exits, how long to keep reading stdout / stderr. A grandchild process that * inherited the pipes can keep them open indefinitely; the streams are destroyed afterwards. @@ -32,28 +47,60 @@ export const processTimings = { const STDERR_TAIL_BYTES = 64 * 1024; const liveChildren = new Set(); +const tempDirectories = new Set(); let exitHookInstalled = false; -function trackChild(child: ChildProcess): void { - liveChildren.add(child); +/** Registers a prompt directory so `process.exit()` can delete it synchronously. */ +export function trackTempDir(directory: string): void { + tempDirectories.add(directory); + ensureExitHook(); +} + +export function untrackTempDir(directory: string): void { + tempDirectories.delete(directory); +} + +/** + * SIGTERM to live CLI processes, then `rmSync` of prompt directories. Installed as the `exit` + * hook: asynchronous cleanup does not run after `process.exit()`. + */ +export function releaseTrackedResources(): void { + for (const live of liveChildren) { + try { + // The CLI treats SIGTERM as a graceful cancel. + live.kill('SIGTERM'); + } catch { + // The child already exited. + } + } + liveChildren.clear(); + for (const directory of tempDirectories) { + try { + rmSync(directory, { recursive: true, force: true }); + } catch { + // Already removed. + } + } + tempDirectories.clear(); +} + +function ensureExitHook(): void { if (exitHookInstalled) return; exitHookInstalled = true; - // The CLI treats SIGTERM as a graceful cancel. Without this, children outlive the host. - process.once('exit', () => { - for (const live of liveChildren) { - try { - live.kill('SIGTERM'); - } catch { - // The child already exited. - } - } - }); + process.on('exit', releaseTrackedResources); +} + +function trackChild(child: ChildProcess): void { + liveChildren.add(child); + ensureExitHook(); } export interface ExecProcessOptions { readonly command: SpawnCommand; readonly env: Readonly>; readonly prompt: string; + /** Test seam. Production uses `node:child_process` `spawn` with `shell: false`. */ + readonly spawn?: typeof spawn; readonly signal?: AbortSignal; /** CLI-side timeout; enables the SDK backstop at `timeoutMs + backstopExtraMs`. */ readonly timeoutMs?: number; @@ -92,13 +139,18 @@ export class ExecProcess { constructor(private readonly options: ExecProcessOptions) { const { command } = options; - this.child = spawn(command.command, command.args, { + const spawnChild = options.spawn ?? spawn; + this.child = spawnChild(command.command, command.args, { env: options.env as NodeJS.ProcessEnv, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + shell: false, }); this.sessionId = options.sessionId; trackChild(this.child); + // Before attach(): a failed spawn emits 'error' with null stdio on the next tick, and + // reading stdout throws before a listener registered later in attach() can run. + this.child.once('error', (error: NodeJS.ErrnoException) => this.onChildError(error)); this.attach(); options.signal?.addEventListener('abort', this.onAbort, { once: true }); // `abort` listeners added after the signal fired are never called. @@ -144,20 +196,40 @@ export class ExecProcess { } } - /** SIGINT, then SIGTERM after the grace period, then SIGKILL. */ + /** + * SIGTERM, then SIGKILL after {@link processTimings.cancelGraceMs}. + * SIGTERM is the CLI's cancel signal (same path as SIGINT). Starting there avoids a second + * SIGINT after Ctrl+C, which would hit the default action once the CLI's one-shot SIGINT + * handler has already run and would skip shutdown and `exec.completed`. On Windows, + * `kill('SIGTERM')` is a hard kill. + */ interrupt(): void { if (this.exited || this.abortRequested) return; this.abortRequested = true; - // Windows cannot deliver a catchable SIGINT; kill() terminates the process there. - this.kill(process.platform === 'win32' ? 'SIGTERM' : 'SIGINT'); - this.schedule(processTimings.interruptGraceMs, () => { - this.kill('SIGTERM'); - this.schedule(processTimings.terminateGraceMs, () => this.kill('SIGKILL')); - }); + this.kill('SIGTERM'); + this.schedule(processTimings.cancelGraceMs, () => this.kill('SIGKILL')); + } + + private onChildError(error: NodeJS.ErrnoException): void { + this.exited = true; + this.fail( + error.code === 'ENOENT' || error.code === 'EACCES' + ? new MCodeNotFoundError(`Could not start mcode (${error.code}).`, { cause: error }) + : new MCodeError(`Could not start mcode: ${error.message}`, { cause: error }), + ); + this.cleanup(); } private attach(): void { const { child } = this; + const { stdin, stdout, stderr } = child; + if (!stdin || !stdout || !stderr) { + this.exited = true; + const error = new MCodeError('mcode stdout is unavailable.'); + this.fail(error); + this.cleanup(); + throw error; + } let lines = 0; let streamsOpen = 2; let settled = false; @@ -168,21 +240,20 @@ export class ExecProcess { this.settle(exit.code, exit.signal); }; - child.stdin?.on('error', () => { + stdin.on('error', () => { // EPIPE when the CLI exits before reading stdin; the exit code reports the cause. }); - child.stdin?.end(JSON.stringify({ prompt: this.options.prompt })); + stdin.end(JSON.stringify({ prompt: this.options.prompt })); - child.stderr?.setEncoding('utf8').on('data', (chunk: string) => { + stderr.setEncoding('utf8').on('data', (chunk: string) => { this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_TAIL_BYTES); }); - child.stderr?.once('close', () => { + stderr.once('close', () => { streamsOpen -= 1; settle(); }); - if (!child.stdout) throw new MCodeError('mcode stdout is unavailable.'); - const reader = createInterface({ input: child.stdout, crlfDelay: Infinity }); + const reader = createInterface({ input: stdout, crlfDelay: Infinity }); reader.on('line', (line) => { if (this.finished || this.completed) return; lines += 1; @@ -203,15 +274,6 @@ export class ExecProcess { settle(); }); - child.once('error', (error: NodeJS.ErrnoException) => { - this.exited = true; - this.fail( - error.code === 'ENOENT' || error.code === 'EACCES' - ? new MCodeNotFoundError(`Could not start mcode (${error.code}).`, { cause: error }) - : new MCodeError(`Could not start mcode: ${error.message}`, { cause: error }), - ); - this.cleanup(); - }); child.once('exit', (code, signal) => { this.exited = true; exit = { code, signal }; diff --git a/packages/code-sdk/src/mcode.ts b/packages/code-sdk/src/mcode.ts index 891bd27be..d63d80e51 100644 --- a/packages/code-sdk/src/mcode.ts +++ b/packages/code-sdk/src/mcode.ts @@ -46,6 +46,7 @@ export class MCode { const env = this.childEnv(); const cli = resolveCli({ ...(this.options.bin === undefined ? {} : { bin: this.options.bin }), + ...(this.options.resolveFromCwd ? { resolveFromCwd: true } : {}), env, }); await checkCliVersion(cli, env); diff --git a/packages/code-sdk/src/thread.ts b/packages/code-sdk/src/thread.ts index d373954a5..362a55adb 100644 --- a/packages/code-sdk/src/thread.ts +++ b/packages/code-sdk/src/thread.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { buildExecArgs, validateRunOptions } from './args.js'; import { buildSpawnCommand, type ResolvedCli } from './cli-resolver.js'; import { errorForResult, MCodeAbortError, MCodeProtocolError, ThreadBusyError } from './errors.js'; -import { ExecProcess } from './exec-process.js'; +import { ExecProcess, trackTempDir, untrackTempDir } from './exec-process.js'; import { ItemAccumulator } from './items.js'; import type { ExecResult, @@ -104,7 +104,9 @@ export class Thread { let tempDir: string | undefined; const release = async (): Promise => { this.busy = false; - if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + if (!tempDir) return; + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + untrackTempDir(tempDir); }; try { if (typeof input !== 'string') throw new TypeError('input must be a string.'); @@ -118,6 +120,7 @@ export class Thread { this.options.appendSystemPrompt !== undefined ) { tempDir = await mkdtemp(join(tmpdir(), 'mcode-sdk-')); + trackTempDir(tempDir); if (this.options.systemPrompt !== undefined) { promptFiles.systemPromptFile = await writePrivate( join(tempDir, 'system-prompt.txt'), diff --git a/packages/code-sdk/src/types.ts b/packages/code-sdk/src/types.ts index 59da8aeb8..04a9ab76c 100644 --- a/packages/code-sdk/src/types.ts +++ b/packages/code-sdk/src/types.ts @@ -61,13 +61,14 @@ export interface ExecResult { /** * Raw tool call snapshot forwarded from the Runtime. * - * @experimental `status` is an internal numeric enum and `input` / `output` are tool-specific - * structures. Neither is covered by the SDK compatibility promise. + * @experimental `status` matches the CLI (`string | number`; often an internal numeric enum) and + * `input` / `output` are tool-specific structures. Neither is covered by the SDK compatibility + * promise. */ export interface RawToolCall { readonly id?: string; readonly name?: string; - readonly status?: number; + readonly status?: string | number; readonly input?: unknown; readonly output?: unknown; readonly durationMs?: number; @@ -185,6 +186,13 @@ export type PromptMode = 'tui' | 'coding' | 'work'; export interface MCodeOptions { /** Explicit `mcode` executable or JS entry. Skips package and PATH lookup. */ readonly bin?: string; + /** + * Search `process.cwd()` for the CLI: `@minimax-ai/code` from that directory's `package.json`, + * then `node_modules/.bin/mcode` walking up through parent directories. Both steps run before + * `PATH`. Default `false`. Leave this off when the working directory is an untrusted checkout + * (for example CI); pass `bin` or use a CLI installed next to the SDK or on `PATH`. + */ + readonly resolveFromCwd?: boolean; /** Data directory for Sessions and config; passed to the CLI as `MINIMAX_DATA_DIR`. */ readonly dataDir?: string; /** Child process environment. Replaces `process.env` when provided. */ diff --git a/packages/code-sdk/src/version.ts b/packages/code-sdk/src/version.ts index d61c2aae1..c88848eb3 100644 --- a/packages/code-sdk/src/version.ts +++ b/packages/code-sdk/src/version.ts @@ -1,10 +1,16 @@ import { spawn } from 'node:child_process'; import { buildSpawnCommand, type ResolvedCli } from './cli-resolver.js'; -import { MCODE_INSTALL_HINT, MCodeNotFoundError, MCodeVersionError } from './errors.js'; +import { MCODE_INSTALL_HINT, MCodeError, MCodeNotFoundError, MCodeVersionError } from './errors.js'; -/** Minimum supported CLI version; the public 0.7.0 release accepts every flag the SDK passes. */ -export const MIN_CLI_VERSION = '0.7.0'; +/** + * First CLI release whose `mcode exec` flags cover everything the SDK passes. + * `--system-prompt-file`, `--append-system-prompt-file` and `--mode` landed in 0.6.3 + * (`packages/tui/CHANGELOG.md`); `--output-format stream-json` and the other exec flags were + * already present. This repository's CLI is 0.6.5, so a source build of this tree satisfies the + * check. Preview builds such as `0.6.3-preview.N` satisfy `0.6.3`. + */ +export const MIN_CLI_VERSION = '0.6.3'; const VERSION_TIMEOUT_MS = 30_000; @@ -27,7 +33,7 @@ export function parseVersion(text: string): ParsedVersion | undefined { } /** - * Compares only `major.minor.patch`. Preview builds such as `0.7.0-preview.N` satisfy `0.7.0` + * Compares only `major.minor.patch`. Preview builds such as `0.6.3-preview.N` satisfy `0.6.3` * because the preview channel ships the same exec contract as its release line. */ export function satisfiesMinimum(version: ParsedVersion, minimum: ParsedVersion): boolean { @@ -40,8 +46,9 @@ export function satisfiesMinimum(version: ParsedVersion, minimum: ParsedVersion) export async function checkCliVersion( cli: ResolvedCli, env: Readonly>, + spawnChild: typeof spawn = spawn, ): Promise { - const output = await readVersionOutput(cli, env); + const output = await readVersionOutput(cli, env, spawnChild); const parsed = parseVersion(output); const minimum = parseVersion(MIN_CLI_VERSION) as ParsedVersion; if (!parsed) { @@ -66,43 +73,69 @@ export async function checkCliVersion( function readVersionOutput( cli: ResolvedCli, env: Readonly>, + spawnChild: typeof spawn, ): Promise { const command = buildSpawnCommand(cli, ['--version']); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; - const child = spawn(command.command, command.args, { + let settled = false; + let timer: NodeJS.Timeout | undefined; + const finish = (action: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + action(); + }; + const child = spawnChild(command.command, command.args, { env: env as NodeJS.ProcessEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + shell: false, }); - const timer = setTimeout(() => child.kill('SIGKILL'), VERSION_TIMEOUT_MS); + // Attach before any use of stdio. A failed spawn (EMFILE) emits 'error' on the next tick + // with null pipes; touching stdout first throws and leaves that event unhandled. + child.once('error', (error: NodeJS.ErrnoException) => { + finish(() => { + reject( + error.code === 'ENOENT' || error.code === 'EACCES' + ? new MCodeNotFoundError(`Could not start mcode at ${cli.path}.`, { cause: error }) + : error, + ); + }); + }); + timer = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // Already gone. + } + }, VERSION_TIMEOUT_MS); + if (!child.stdout || !child.stderr) { + finish(() => { + reject(new MCodeError('mcode stdout is unavailable.')); + }); + return; + } child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk; }); child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk; }); - child.once('error', (error: NodeJS.ErrnoException) => { - clearTimeout(timer); - reject( - error.code === 'ENOENT' || error.code === 'EACCES' - ? new MCodeNotFoundError(`Could not start mcode at ${cli.path}.`, { cause: error }) - : error, - ); - }); child.once('close', (code, signal) => { - clearTimeout(timer); - if (code === 0) { - resolve(stdout); - return; - } - reject( - new MCodeVersionError( - `\`mcode --version\` failed (${String(code ?? signal)}); cannot verify CLI compatibility.`, - { exitCode: code, signal, stderr: stderr.slice(-4096) }, - ), - ); + finish(() => { + if (code === 0) { + resolve(stdout); + return; + } + reject( + new MCodeVersionError( + `\`mcode --version\` failed (${String(code ?? signal)}); cannot verify CLI compatibility.`, + { exitCode: code, signal, stderr: stderr.slice(-4096) }, + ), + ); + }); }); }); } diff --git a/packages/code-sdk/test/args.test.ts b/packages/code-sdk/test/args.test.ts index 2850617c3..4129f105d 100644 --- a/packages/code-sdk/test/args.test.ts +++ b/packages/code-sdk/test/args.test.ts @@ -5,8 +5,16 @@ import { describe, expect, it } from 'vitest'; import { MAX_TIMEOUT_MS, validateRunOptions } from '../src/args.js'; import { MCode, MCodeInvocationError, MCodeRunError } from '../src/index.js'; +import type { RawToolCall } from '../src/types.js'; import { FAKE_MCODE, fakeClient, fixture, readRecord, tempDir } from './helpers.js'; +describe('tool call status', () => { + it('accepts the CLI status union of string and number', () => { + const statuses: Array = ['completed', 2]; + expect(statuses).toEqual(['completed', 2]); + }); +}); + describe('CLI argument mapping', () => { it('maps Thread, run and client options to exec flags, stdin and environment', async () => { const dir = tempDir(); @@ -134,7 +142,7 @@ describe('CLI argument mapping', () => { it('rejects timeoutMs values whose SDK backstop would overflow a Node timer', async () => { expect(() => validateRunOptions({ timeoutMs: MAX_TIMEOUT_MS })).not.toThrow(); - // 2_200_000_000 + 30s used to overflow and fire the force-kill after ~1ms. + // A backstop past the signed 32-bit timer limit used to fire the force-kill after ~1ms. const record = `${tempDir()}/record.json`; await expect( fakeClient({ FAKE_MCODE_RECORD: record }) diff --git a/packages/code-sdk/test/cli-contract.test.ts b/packages/code-sdk/test/cli-contract.test.ts index 4aa57537c..953eb0155 100644 --- a/packages/code-sdk/test/cli-contract.test.ts +++ b/packages/code-sdk/test/cli-contract.test.ts @@ -1,22 +1,63 @@ /** * Contract between the SDK's self-held types and the mcode CLI source. * - * Imports the CLI's exit-code policy and its real JSONL projector from `packages/tui` (both are - * free of runtime dependencies) and checks that the SDK parses, normalizes and maps them as - * documented. Excluded from `tsc` because the CLI sources type-check only inside their package. + * Imports the CLI's exit-code policy, its real JSONL projector and its exec argument parser from + * `packages/tui` and checks that the SDK parses, normalizes and maps them as documented. Excluded + * from `tsc` because the CLI sources type-check only inside their package. */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'vitest'; +import { applyExecCliContract } from '../../tui/src/cli/contract.js'; import { ExecEventProjector } from '../../tui/src/headless/events.js'; import { exitCodeForExecResult, MINIMAX_CODE_EXEC_EXIT_CODES, } from '../../tui/src/headless/exit-policy.js'; +import { + resolveTuiExecInvocation, + type RawTuiExecOptions, +} from '../../tui/src/headless/invocation.js'; +import { buildExecArgs } from '../src/args.js'; import { errorForExit, errorForResult, MCODE_EXEC_EXIT_CODES } from '../src/errors.js'; import { MCodeAbortError, MCodeRunError } from '../src/index.js'; import { ItemAccumulator } from '../src/items.js'; import { parseEventLine } from '../src/protocol.js'; import type { ExecResult } from '../src/types.js'; +import { MIN_CLI_VERSION, parseVersion, satisfiesMinimum } from '../src/version.js'; +import { tempDir } from './helpers.js'; + +const { Command } = createRequire(new URL('../../tui/package.json', import.meta.url))( + 'commander', +) as { + Command: new () => { + name(name: string): void; + exitOverride(): void; + command(name: string): object; + parseAsync(argv: readonly string[], options: { from: 'node' }): Promise; + }; +}; + +async function parseWithCli(args: readonly string[], stdin: string) { + const program = new Command(); + program.name('mcode'); + program.exitOverride(); + program.configureOutput({ writeOut: () => undefined, writeErr: () => undefined }); + let captured: { prompt: string | undefined; options: RawTuiExecOptions } | undefined; + const exec = program.command('exec'); + applyExecCliContract(exec as never).action( + (prompt: string | undefined, options: RawTuiExecOptions) => { + captured = { prompt, options }; + }, + ); + await program.parseAsync(['node', 'mcode', ...args], { from: 'node' }); + if (!captured) throw new Error('the CLI exec action did not run'); + return resolveTuiExecInvocation(captured.prompt, captured.options, async () => stdin); +} const ids = { runId: 'exec_turn_1', sessionId: 'mvs_contract', turnId: 'turn_1' }; @@ -112,4 +153,85 @@ describe('mcode exec contract', () => { const completed = parsed.at(-1); expect(completed?.type === 'exec.completed' && completed.result).toEqual(finalResult); }); + + it('accepts the CLI in this repository under the SDK minimum', () => { + const repo = JSON.parse( + readFileSync(fileURLToPath(new URL('../../tui/package.json', import.meta.url)), 'utf8'), + ) as { version: string }; + const repoVersion = parseVersion(repo.version); + const minimum = parseVersion(MIN_CLI_VERSION); + if (!repoVersion || !minimum) throw new Error('unparseable CLI version'); + expect(satisfiesMinimum(repoVersion, minimum)).toBe(true); + }); + + it('parses buildExecArgs and the stdin payload with the CLI parser', async () => { + const dir = tempDir(); + const promptFile = join(dir, 'system-prompt.txt'); + const appendFile = join(dir, 'append-system-prompt.txt'); + const configFile = join(dir, 'config.yaml'); + const attachment = join(dir, 'ci.log'); + writeFileSync(promptFile, 'You are a release bot.\n'); + writeFileSync(appendFile, 'Keep answers short.\n'); + writeFileSync(configFile, 'model: test\n'); + writeFileSync(attachment, 'log\n'); + mkdirSync(join(dir, 'workspace')); + + const stdin = JSON.stringify({ prompt: 'Diagnose the failing job' }); + const args = buildExecArgs({ + configPath: configFile, + thread: { + cwd: join(dir, 'workspace'), + model: 'minimax/MiniMax-M3', + effort: 'high', + permission: 'full', + mode: 'lightweight', + promptMode: 'coding', + }, + sessionId: null, + run: { files: [attachment], timeoutMs: 600_000, maxSteps: 12 }, + systemPromptFile: promptFile, + appendSystemPromptFile: appendFile, + }); + + const invocation = await parseWithCli(args, stdin); + + expect(invocation.prompt).toBe('Diagnose the failing job'); + expect(invocation.format).toBe('stream-json'); + expect(invocation.permission).toBe('full'); + expect(invocation.promptMode).toBe('coding'); + expect(invocation.contextMode).toBe('lightweight'); + expect(invocation.model).toBe('minimax/MiniMax-M3'); + expect(invocation.effort).toBe('high'); + expect(invocation.timeoutMs).toBe(600_000); + expect(invocation.maxSteps).toBe(12); + expect(invocation.systemPromptOverrides).toEqual({ + customPrompt: 'You are a release bot.\n', + appendSystemPrompt: 'Keep answers short.\n', + }); + expect(invocation.attachments.map((file) => file.fileName)).toEqual(['ci.log']); + + const resumed = buildExecArgs({ + thread: { cwd: join(dir, 'workspace') }, + sessionId: 'mvs_contract', + run: {}, + }); + expect((await parseWithCli(resumed, JSON.stringify({ prompt: 'continue' }))).sessionId).toBe( + 'mvs_contract', + ); + + await expect(parseWithCli([...args, '--no-such-flag'], stdin)).rejects.toThrow(/no-such-flag/u); + await expect( + parseWithCli( + args.map((arg) => (arg.startsWith('--permission=') ? '--permission=bogus' : arg)), + stdin, + ), + ).rejects.toThrow(/permission/u); + await expect( + parseWithCli( + args.map((arg) => (arg.startsWith('--timeout=') ? '--timeout=garbage' : arg)), + stdin, + ), + ).rejects.toThrow(/timeout/u); + await expect(parseWithCli(args, '{')).rejects.toThrow(/json/iu); + }); }); diff --git a/packages/code-sdk/test/cli-resolution.test.ts b/packages/code-sdk/test/cli-resolution.test.ts index 0ab5a4ead..08be88e74 100644 --- a/packages/code-sdk/test/cli-resolution.test.ts +++ b/packages/code-sdk/test/cli-resolution.test.ts @@ -12,7 +12,12 @@ import { resolveCli, resolvePackageEntry, } from '../src/cli-resolver.js'; -import { parseVersion, satisfiesMinimum } from '../src/version.js'; +import { + checkCliVersion, + MIN_CLI_VERSION, + parseVersion, + satisfiesMinimum, +} from '../src/version.js'; import { FAKE_MCODE, fakeClient, fixture, tempDir } from './helpers.js'; const isWindows = process.platform === 'win32'; @@ -125,23 +130,48 @@ describe('CLI version check', () => { it('compares major.minor.patch and accepts preview builds of the minimum', () => { const satisfies = (text: string): boolean => { const version = parseVersion(text); - const minimum = parseVersion('0.7.0'); + const minimum = parseVersion(MIN_CLI_VERSION); if (!version || !minimum) throw new Error(`unparseable version ${text}`); return satisfiesMinimum(version, minimum); }; + expect(MIN_CLI_VERSION).toBe('0.6.3'); + expect(satisfies('0.6.3-preview.1')).toBe(true); + expect(satisfies('0.6.3')).toBe(true); + expect(satisfies('0.6.5')).toBe(true); expect(satisfies('0.7.0-preview.1015121')).toBe(true); expect(satisfies('mcode 0.7.1')).toBe(true); expect(satisfies('1.0.0')).toBe(true); - expect(satisfies('0.6.12')).toBe(false); + expect(satisfies('0.6.2')).toBe(false); + }); + + it('accepts this repository CLI and the minimum, and rejects the version before it', async () => { + const cli = resolveCli({ bin: FAKE_MCODE, env: {} }); + const repoVersion = ( + JSON.parse( + readFileSync(fileURLToPath(new URL('../../tui/package.json', import.meta.url)), 'utf8'), + ) as { + version: string; + } + ).version; + await expect(checkCliVersion(cli, { FAKE_MCODE_VERSION: MIN_CLI_VERSION })).resolves.toBe( + MIN_CLI_VERSION, + ); + await expect(checkCliVersion(cli, { FAKE_MCODE_VERSION: repoVersion })).resolves.toBe( + repoVersion, + ); + await expect(checkCliVersion(cli, { FAKE_MCODE_VERSION: '0.7.0' })).resolves.toBe('0.7.0'); + await expect(checkCliVersion(cli, { FAKE_MCODE_VERSION: '0.6.2' })).rejects.toBeInstanceOf( + MCodeVersionError, + ); }); it('rejects an older CLI before running', async () => { - const error = await fakeClient({ FAKE_MCODE_VERSION: '0.6.12' }) + const error = await fakeClient({ FAKE_MCODE_VERSION: '0.6.2' }) .startThread() .run('x') .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(MCodeVersionError); - expect((error as MCodeVersionError).version).toBe('0.6.12'); + expect((error as MCodeVersionError).version).toBe('0.6.2'); expect((error as Error).message).toContain('npm install @minimax-ai/code@latest'); }); @@ -164,5 +194,11 @@ describe('package metadata', () => { engines: { node: string }; }; expect(read('../package.json').engines.node).toBe(read('../../tui/package.json').engines.node); + const sdk = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), + ) as { + peerDependencies: { '@minimax-ai/code': string }; + }; + expect(sdk.peerDependencies['@minimax-ai/code']).toBe(`>=${MIN_CLI_VERSION}`); }); }); diff --git a/packages/code-sdk/test/exit-codes.test.ts b/packages/code-sdk/test/exit-codes.test.ts index 620c73cab..684d7faa5 100644 --- a/packages/code-sdk/test/exit-codes.test.ts +++ b/packages/code-sdk/test/exit-codes.test.ts @@ -1,14 +1,25 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { EventEmitter } from 'node:events'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ChildProcess, spawn } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; import { MCodeAbortError, + MCodeError, MCodeInvocationError, MCodeProtocolError, MCodeRunError, } from '../src/index.js'; -import { processTimings } from '../src/exec-process.js'; +import { + ExecProcess, + processTimings, + releaseTrackedResources, + trackTempDir, +} from '../src/exec-process.js'; +import { checkCliVersion } from '../src/version.js'; import { fakeClient, fixture, tempDir } from './helpers.js'; const posixOnly = process.platform === 'win32' ? it.skip : it; @@ -117,9 +128,9 @@ describe('process exit with inherited pipes', () => { }); describe('cancellation escalation and timeout backstop', () => { - posixOnly('escalates SIGINT to SIGTERM and SIGKILL when the CLI ignores signals', async () => { + posixOnly('escalates SIGTERM to SIGKILL when the CLI ignores the cancel signal', async () => { const original = { ...processTimings }; - Object.assign(processTimings, { interruptGraceMs: 50, terminateGraceMs: 50 }); + Object.assign(processTimings, { cancelGraceMs: 50 }); try { const mcode = fakeClient({ FAKE_MCODE_FIXTURE: fixture('cancelled'), @@ -168,3 +179,75 @@ describe('cancellation escalation and timeout backstop', () => { } }); }); + +function nullStdioChild(): ChildProcess { + const child = new EventEmitter() as ChildProcess; + child.stdin = null; + child.stdout = null; + child.stderr = null; + child.kill = () => true; + return child; +} + +describe('spawn without stdio', () => { + it('turns a null-stdio child into MCodeError and handles the delayed error event', async () => { + const child = nullStdioChild(); + const uncaught: unknown[] = []; + const onUncaught = (error: unknown): void => { + uncaught.push(error); + }; + process.on('uncaughtException', onUncaught); + try { + expect( + () => + new ExecProcess({ + command: { command: 'mcode', args: ['exec'] }, + env: {}, + prompt: 'x', + spawn: (() => child) as typeof spawn, + }), + ).toThrow(MCodeError); + child.emit('error', Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })); + await new Promise((resolve) => setImmediate(resolve)); + expect(uncaught).toEqual([]); + } finally { + process.off('uncaughtException', onUncaught); + } + }); + + it('rejects a version check with null stdio and clears the failure timer', async () => { + const child = nullStdioChild(); + const uncaught: unknown[] = []; + const onUncaught = (error: unknown): void => { + uncaught.push(error); + }; + process.on('uncaughtException', onUncaught); + try { + const pending = checkCliVersion( + { kind: 'exec', path: 'mcode', source: 'path' }, + {}, + (() => child) as typeof spawn, + ); + const rejected = expect(pending).rejects.toBeInstanceOf(MCodeError); + child.emit('error', Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })); + await rejected; + await new Promise((resolve) => setImmediate(resolve)); + expect(uncaught).toEqual([]); + } finally { + process.off('uncaughtException', onUncaught); + } + }); +}); + +describe('process exit cleanup', () => { + it('removes tracked prompt directories synchronously', () => { + const directory = mkdtempSync(join(tmpdir(), 'mcode-sdk-')); + writeFileSync(join(directory, 'system-prompt.txt'), 'secret', { mode: 0o600 }); + trackTempDir(directory); + expect(process.listeners('exit')).toContain(releaseTrackedResources); + + releaseTrackedResources(); + + expect(existsSync(directory)).toBe(false); + }); +}); diff --git a/packages/code-sdk/test/fixtures/cli-package/mcode.mjs b/packages/code-sdk/test/fixtures/cli-package/mcode.mjs index 41aeb9ebe..921aeae76 100755 --- a/packages/code-sdk/test/fixtures/cli-package/mcode.mjs +++ b/packages/code-sdk/test/fixtures/cli-package/mcode.mjs @@ -9,6 +9,7 @@ // FAKE_MCODE_STDERR text written to stderr before exiting // FAKE_MCODE_RECORD file that receives { argv, stdin, env, promptFiles } as JSON // FAKE_MCODE_MODE 'normal' | 'wait-sigint' | 'ignore-signals' | 'hang' +// FAKE_MCODE_SIGNAL_LOG wait-sigint: file that receives the cancel signal name // FAKE_MCODE_SPLIT non-normal modes: lines replayed before waiting (default 3) // FAKE_MCODE_PREFIX raw text written to stdout before the fixture // FAKE_MCODE_GRANDCHILD_PID normal mode: before exiting, spawn a long-lived grandchild that @@ -73,10 +74,15 @@ function run() { const keepAlive = setInterval(() => {}, 1_000); // Install handlers before replaying, so a consumer that saw an event can signal safely. if (mode === 'wait-sigint') { - process.once('SIGINT', () => { + // The CLI cancels on SIGINT and SIGTERM. The SDK sends SIGTERM so a terminal Ctrl+C + // (SIGINT to the process group) does not consume the only cancel handler. + const finishCancel = (signal) => { + if (env.FAKE_MCODE_SIGNAL_LOG) writeFileSync(env.FAKE_MCODE_SIGNAL_LOG, signal); clearInterval(keepAlive); finish(lines.slice(split), 130); - }); + }; + process.once('SIGINT', () => finishCancel('SIGINT')); + process.once('SIGTERM', () => finishCancel('SIGTERM')); } else if (mode === 'ignore-signals') { process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); diff --git a/packages/code-sdk/test/helpers.ts b/packages/code-sdk/test/helpers.ts index 6efdd975e..0abfc5f07 100644 --- a/packages/code-sdk/test/helpers.ts +++ b/packages/code-sdk/test/helpers.ts @@ -47,6 +47,7 @@ export interface FakeEnv { readonly FAKE_MCODE_VERSION_LOG?: string; readonly FAKE_MCODE_RECORD?: string; readonly FAKE_MCODE_GRANDCHILD_PID?: string; + readonly FAKE_MCODE_SIGNAL_LOG?: string; } export function fakeClient(fake: FakeEnv, options: Omit = {}): MCode { diff --git a/packages/code-sdk/test/project-bin.test.ts b/packages/code-sdk/test/project-bin.test.ts index 835b9e43e..a14e91f86 100644 --- a/packages/code-sdk/test/project-bin.test.ts +++ b/packages/code-sdk/test/project-bin.test.ts @@ -35,7 +35,7 @@ function project(): { root: string; entry: string; bin: string } { } function versionOf(command: ReturnType): string { - return spawnSync(command.command, command.args, { encoding: 'utf8' }).stdout.trim(); + return spawnSync(command.command, command.args, { encoding: 'utf8', shell: false }).stdout.trim(); } describe('project-local node_modules/.bin/mcode', () => { @@ -100,6 +100,33 @@ describe('project-local node_modules/.bin/mcode', () => { expect(findProjectBin(tempDir())).toBeUndefined(); }); + it('searches the working directory only when resolveFromCwd is set', () => { + const { root, bin } = project(); + if (process.platform === 'win32') { + writeFileSync( + join(bin, 'mcode.cmd'), + '"%~dp0\\..\\@scope\\any-mcode-cli\\dist\\index.js" %*', + ); + } else { + symlinkSync(join('..', '@scope', 'any-mcode-cli', 'dist', 'index.js'), join(bin, 'mcode')); + } + const onPath = tempDir(); + const pathBin = join(onPath, process.platform === 'win32' ? 'mcode.cmd' : 'mcode'); + writeFileSync(pathBin, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n'); + chmodSync(pathBin, 0o755); + const shared = { + env: { PATH: onPath, PATHEXT: '.CMD' }, + packageSearchFrom: [] as string[], + cwd: root, + }; + + expect(resolveCli(shared).source).toBe('path'); + expect(resolveCli({ ...shared, resolveFromCwd: true }).source).toBe('project'); + expect(() => resolveCli({ env: { PATH: '' }, packageSearchFrom: [], cwd: root })).toThrow( + /resolveFromCwd/u, + ); + }); + it('parses Windows cmd and PowerShell shims to the JS entry', () => { const { entry, bin } = project(); const cmd = join(bin, 'mcode.cmd'); diff --git a/packages/code-sdk/test/release-check.test.ts b/packages/code-sdk/test/release-check.test.ts index 4ebdc2242..9293f9d46 100644 --- a/packages/code-sdk/test/release-check.test.ts +++ b/packages/code-sdk/test/release-check.test.ts @@ -1,17 +1,25 @@ import { spawnSync } from 'node:child_process'; -import { writeFileSync, type PathLike } from 'node:fs'; +import { readFileSync, writeFileSync, type PathLike } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { + DIRECTORY_PUBLISH_MESSAGE, + isReleaseRehearsal, + LOCAL_USER_PATH, NPMJS_TARGET_ARGS, normalizeRegistry, + PACKAGE_TEXT_RULES, publishCommand, readBackCommands, + releaseClosingLines, + REQUIRED_TARBALL_ENTRIES, resolveTool, scopedRegistryProblem, + tarballProblems, + textLabels, } from '../scripts/release-tools.mjs'; import { tempDir } from './helpers.js'; @@ -104,3 +112,117 @@ describe('release:check tool invocation', () => { ).toThrow(/Cannot find npm-cli\.js/u); }); }); + +describe('release:check private text and tarball allowlist', () => { + it('matches local user paths and ignores nearby forms', () => { + for (const sample of [ + '/Users/alice/project', + '/home/alice/project', + '/root/.npmrc', + '/private/var/folders/xx/yy/T/tmp', + String.raw`C:\Users\alice\src`, + String.raw`C:\\Users\\alice\\src`, + 'C:/Users/alice/src', + ]) { + expect(LOCAL_USER_PATH.test(sample), sample).toBe(true); + expect(textLabels(sample, PACKAGE_TEXT_RULES)).toContain('local user path'); + } + for (const sample of [ + '/opt/minimax/app', + '/home/', + 'home/alice/file', + '/private/var/log/system.log', + '/opt/root-tools/bin', + 'chroot/bin', + 'Users/alice', + String.raw`D:\Temp\build`, + 'packages/code-sdk/src/index.ts', + ]) { + expect(LOCAL_USER_PATH.test(sample), sample).toBe(false); + } + expect(textLabels('packages/tui/src/cli.ts', PACKAGE_TEXT_RULES)).toContain( + 'repository package path', + ); + expect(textLabels('packages/code-sdk/src/index.ts', PACKAGE_TEXT_RULES)).not.toContain( + 'repository package path', + ); + expect(textLabels('@mavis/config', PACKAGE_TEXT_RULES)).toContain('internal package name'); + expect(textLabels('hello from the sdk', PACKAGE_TEXT_RULES)).toEqual([]); + }); + + it('accepts the public file set and rejects paths outside it', () => { + expect(tarballProblems([...REQUIRED_TARBALL_ENTRIES, 'package/src/index.ts'])).toEqual({ + unexpected: [], + missing: [], + }); + expect(tarballProblems(['package/', ...REQUIRED_TARBALL_ENTRIES]).unexpected).toEqual([]); + expect( + tarballProblems([ + ...REQUIRED_TARBALL_ENTRIES, + 'package/scripts/release-check.mjs', + 'package/../../etc/passwd', + 'package/src/nested/secret.ts', + ]).unexpected, + ).toEqual([ + 'package/scripts/release-check.mjs', + 'package/../../etc/passwd', + 'package/src/nested/secret.ts', + ]); + expect(tarballProblems(['package/package.json']).missing).toContain('package/dist/index.js'); + }); + + it('prints a publish command only for an up-to-date main', () => { + const head = 'a'.repeat(40); + expect(isReleaseRehearsal({ branch: 'feat/code-sdk', head, remoteMain: head })).toBe(true); + expect(isReleaseRehearsal({ branch: 'main', head, remoteMain: 'b'.repeat(40) })).toBe(true); + expect(isReleaseRehearsal({ branch: 'main', head, remoteMain: head })).toBe(false); + + const shared = { + spec: '@minimax-ai/code-sdk@0.1.0', + releaseBranch: 'main', + tarball: '/tmp/code-sdk-release-xx/minimax-ai-code-sdk-0.1.0.tgz', + integrity: 'sha512-abc', + version: '0.1.0', + name: '@minimax-ai/code-sdk', + }; + const rehearsal = releaseClosingLines({ ...shared, rehearsal: true }).join('\n'); + expect(rehearsal).toMatch(/Do not publish/u); + expect(rehearsal).not.toMatch(/npm publish/u); + const ready = releaseClosingLines({ ...shared, rehearsal: false }).join('\n'); + expect(ready).toMatch(/npm publish/u); + expect(ready).toContain(shared.tarball); + expect(ready).not.toMatch(/Do not publish/u); + }); +}); + +describe('directory publish guard', () => { + const packageDir = fileURLToPath(new URL('..', import.meta.url)); + const guard = fileURLToPath(new URL('../scripts/release-tools.mjs', import.meta.url)); + + it('is the prepublishOnly script and exits before a directory publish', () => { + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + scripts: { prepublishOnly: string }; + private?: boolean; + }; + expect(manifest.private).not.toBe(true); + expect(manifest.scripts.prepublishOnly).toBe('node scripts/release-tools.mjs'); + + const direct = spawnSync(process.execPath, [guard], { encoding: 'utf8' }); + expect(direct.status).toBe(1); + expect(direct.stderr).toContain(DIRECTORY_PUBLISH_MESSAGE); + + const publish = spawnSync( + 'npm', + [ + 'publish', + '--dry-run', + '--registry=https://registry.npmjs.org/', + '--@minimax-ai:registry=https://registry.npmjs.org/', + ], + { cwd: packageDir, encoding: 'utf8', timeout: 60_000 }, + ); + expect(publish.status).not.toBe(0); + expect(`${publish.stdout}${publish.stderr}`).toContain('Refusing to publish'); + expect(`${publish.stdout}${publish.stderr}`).not.toMatch(/npm notice.*published/u); + }); +}); diff --git a/packages/code-sdk/test/run.test.ts b/packages/code-sdk/test/run.test.ts index f95dcc590..6354d085f 100644 --- a/packages/code-sdk/test/run.test.ts +++ b/packages/code-sdk/test/run.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { MCodeAbortError, MCodeRunError, ThreadBusyError, type ThreadItem } from '../src/index.js'; @@ -111,11 +113,13 @@ describe('thread.run', () => { expect(second.some((arg) => arg.startsWith('--mode'))).toBe(false); }); - posixOnly('cancels with SIGINT and rejects with an AbortError', async () => { + posixOnly('cancels with SIGTERM and rejects with an AbortError', async () => { + const signalLog = `${tempDir()}/signal.txt`; const mcode = fakeClient({ FAKE_MCODE_FIXTURE: fixture('cancelled'), FAKE_MCODE_MODE: 'wait-sigint', FAKE_MCODE_SPLIT: '9', + FAKE_MCODE_SIGNAL_LOG: signalLog, }); const thread = mcode.startThread(); const controller = new AbortController(); @@ -130,6 +134,7 @@ describe('thread.run', () => { expect(abort.name).toBe('AbortError'); expect(abort.exitCode).toBe(130); expect(abort.result?.status).toBe('cancelled'); + expect(readFileSync(signalLog, 'utf8')).toBe('SIGTERM'); expect(abort.sessionId).toBe('mvs_78b922a823a345728486967e33c95770'); // Neither the message nor the tool call received item.completed before cancellation. expect(abort.items.map((item) => [item.type, item.incomplete])).toEqual([ diff --git a/packages/code-sdk/test/stream.test.ts b/packages/code-sdk/test/stream.test.ts index 1f340fdd7..df9d65378 100644 --- a/packages/code-sdk/test/stream.test.ts +++ b/packages/code-sdk/test/stream.test.ts @@ -208,6 +208,6 @@ describe('thread.runStreamed', () => { })().catch((caught: unknown) => caught); expect(error).toBeInstanceOf(MCodeAbortError); - expect((error as MCodeAbortError).signal).toBe('SIGINT'); + expect((error as MCodeAbortError).signal).toBe('SIGTERM'); }); }); From 552c815d3a969d1a01468fbb6669b220bfb45a99 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 06:41:26 +0000 Subject: [PATCH 10/10] fix(code-sdk): keep published comments free of other package paths The release text scan rejects packages/tui paths in the packed SDK. Describe the CLI version floor and shutdown budget without naming those files. Co-authored-by: DanielWalnut --- packages/code-sdk/src/exec-process.ts | 3 +-- packages/code-sdk/src/version.ts | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/code-sdk/src/exec-process.ts b/packages/code-sdk/src/exec-process.ts index c110f09d9..6049d6b7d 100644 --- a/packages/code-sdk/src/exec-process.ts +++ b/packages/code-sdk/src/exec-process.ts @@ -16,8 +16,7 @@ import { parseEventLine } from './protocol.js'; import type { ThreadEvent, ThreadItem } from './types.js'; /** - * CLI runtime shutdown budget (`DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS` in - * `packages/tui/src/runtime/lifecycle.ts`). Cancel and the timeout backstop wait at least this + * CLI runtime shutdown budget (60 seconds). Cancel and the timeout backstop wait at least this * long so a CLI that is already shutting down can still write `exec.completed`. */ export const CLI_SHUTDOWN_BUDGET_MS = 60_000; diff --git a/packages/code-sdk/src/version.ts b/packages/code-sdk/src/version.ts index c88848eb3..a52170392 100644 --- a/packages/code-sdk/src/version.ts +++ b/packages/code-sdk/src/version.ts @@ -5,10 +5,10 @@ import { MCODE_INSTALL_HINT, MCodeError, MCodeNotFoundError, MCodeVersionError } /** * First CLI release whose `mcode exec` flags cover everything the SDK passes. - * `--system-prompt-file`, `--append-system-prompt-file` and `--mode` landed in 0.6.3 - * (`packages/tui/CHANGELOG.md`); `--output-format stream-json` and the other exec flags were - * already present. This repository's CLI is 0.6.5, so a source build of this tree satisfies the - * check. Preview builds such as `0.6.3-preview.N` satisfy `0.6.3`. + * `--system-prompt-file`, `--append-system-prompt-file` and `--mode` landed in CLI 0.6.3; + * `--output-format stream-json` and the other exec flags were already present. This repository's + * CLI is 0.6.5, so a source build of this tree satisfies the check. Preview builds such as + * `0.6.3-preview.N` satisfy `0.6.3`. */ export const MIN_CLI_VERSION = '0.6.3';