From 82467b8e732a6566e7a3d3e72140b8c45bbf62eb Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 02:59:48 +0000 Subject: [PATCH] fix: drop non-existent --print/--afk flags from the kimi CLI provider argv (#4139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/lib/kimi.js was written without a live `kimi` binary. Verified against kimi v0.32.0: `--print` and `--afk` are not options at all (`error: unknown option`), so every headless kimi-cli run died at argv parsing before doing any work. Non-interactive mode is implicit in supplying `-p`/`--prompt`, and kimi refuses to combine `--prompt` with `--yolo`/`-y`/`--auto`, so the headless path gets no mode flag and no approval posture — only `--model ` when pinned. The seeds (data.reference/providers.json, providers.sample.json) baked `args: ["--print"]` into kimi-cli, and migration 201 shipped that to existing installs, so removing the injection alone would leave deployed installs broken. Migration 269 strips both dead tokens from a stored kimi provider's args wherever they appear — unlike the usual "only rewrite an exactly-matching old default" rule, because neither flag exists in the binary, so keeping one is never a valid user preference. --- .changelog/next/fixed-issue-4139.md | 1 + data.reference/providers.json | 2 +- scripts/migrations/201-kimi-providers.js | 10 +- .../269-kimi-drop-nonexistent-flags.js | 75 +++++++++ .../269-kimi-drop-nonexistent-flags.test.js | 142 ++++++++++++++++++ server/lib/README.md | 2 +- .../aiToolkit/defaults/providers.sample.json | 2 +- server/lib/cliProviderArgs.js | 5 +- server/lib/cliProviderArgs.test.js | 23 +-- server/lib/kimi.js | 91 +++++------ server/lib/kimi.test.js | 94 ++++++++---- server/lib/tuiHandshake.test.js | 4 +- 12 files changed, 359 insertions(+), 92 deletions(-) create mode 100644 .changelog/next/fixed-issue-4139.md create mode 100644 scripts/migrations/269-kimi-drop-nonexistent-flags.js create mode 100644 scripts/migrations/269-kimi-drop-nonexistent-flags.test.js diff --git a/.changelog/next/fixed-issue-4139.md b/.changelog/next/fixed-issue-4139.md new file mode 100644 index 0000000000..5743a234d0 --- /dev/null +++ b/.changelog/next/fixed-issue-4139.md @@ -0,0 +1 @@ +- Kimi Code CLI headless runs no longer fail at startup — PortOS was passing `--print` and `--afk`, neither of which exists in the `kimi` binary (it exits with `unknown option` before running anything). Non-interactive mode is implicit in `--prompt`, and kimi refuses approval flags (`--yolo`/`-y`/`--auto`) alongside `--prompt`, so the headless argv now carries only `--model` (when pinned) plus the prompt. Migration `269-kimi-drop-nonexistent-flags` strips the dead flags from installs that already stored them. (#4139) diff --git a/data.reference/providers.json b/data.reference/providers.json index 5fb58a8c61..08ab5aab27 100644 --- a/data.reference/providers.json +++ b/data.reference/providers.json @@ -323,7 +323,7 @@ "name": "Kimi Code CLI", "type": "cli", "command": "kimi", - "args": ["--print"], + "args": [], "models": ["kimi-configured-default"], "defaultModel": "kimi-configured-default", "lightModel": "kimi-configured-default", diff --git a/scripts/migrations/201-kimi-providers.js b/scripts/migrations/201-kimi-providers.js index 37e63d4d49..58bee893f1 100644 --- a/scripts/migrations/201-kimi-providers.js +++ b/scripts/migrations/201-kimi-providers.js @@ -6,8 +6,14 @@ * process-provider entries: `kimi-cli` (headless one-shot via `kimi --print`) and * `kimi-tui` (interactive PTY). The plain HTTP API entry already exists separately * as `nvidia-kimi`. The CLI/TUI argv conventions live in server/lib/kimi.js (kimi - * reads its prompt as the `--prompt ` argv, not raw stdin; `--print` implies - * `--afk` so headless runs auto-approve). + * reads its prompt as the `--prompt ` argv, not raw stdin). + * + * NOTE (issue #4139): the frozen `KIMI_CLI` def below seeds `args: ["--print"]`, + * which a live `kimi` v0.32.0 rejects outright — the flag does not exist. The def + * is left as-is because it is the historical record of what this migration + * installed; migration 269 strips the token from any install that received it + * (including one seeding it here for the first time, since migrations run in + * numeric order), and the data.reference seed now ships empty args. * * `setup-data.js` merges *missing* provider entries from data.reference, but only * when an install re-runs setup. This migration delivers the providers on a plain diff --git a/scripts/migrations/269-kimi-drop-nonexistent-flags.js b/scripts/migrations/269-kimi-drop-nonexistent-flags.js new file mode 100644 index 0000000000..5634cda7e4 --- /dev/null +++ b/scripts/migrations/269-kimi-drop-nonexistent-flags.js @@ -0,0 +1,75 @@ +/** + * Strip the non-existent `--print` / `--afk` flags from the Kimi Code providers' + * stored args so headless runs stop dying at argv parsing (issue #4139). + * + * Migration 201 seeded `kimi-cli` with `args: ["--print"]`, documented from + * MoonshotAI docs without a live binary to check against. A live `kimi` v0.32.0 + * rejects it outright: + * + * $ kimi --print -p "hello" + * error: unknown option '--print' + * (Did you mean --prompt?) + * + * Non-interactive mode is implicit in supplying `-p`/`--prompt`, so the headless + * argv needs no mode flag at all. `--afk` is equally unrecognized on both the CLI + * and TUI paths. `server/lib/kimi.js` no longer injects either, but a deployed + * install already carries the bad token in `data/providers.json` — `setup-data.js` + * merges only *missing* provider entries and never updates existing ones — so + * without this migration every stored `kimi-cli` stays broken. + * + * Unlike the conservative "rewrite only an exactly-matching old default" rule used + * by 121-codex-tui-bypass-sandbox, this migration removes the two tokens wherever + * they appear in a Kimi provider's args, including a hand-curated list. That is + * safe because neither flag exists in the binary at all: keeping one is never a + * valid user preference, it is a guaranteed startup failure. Every other arg the + * user added is preserved in order, so the rest of their customization survives. + */ + +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +const PROVIDERS_REL_PATH = 'data/providers.json'; + +const TARGET_IDS = ['kimi-cli', 'kimi-tui']; +const DEAD_FLAGS = new Set(['--print', '--afk']); + +export default { + async up({ rootDir }) { + const providersPath = join(rootDir, PROVIDERS_REL_PATH); + const raw = await readFile(providersPath, 'utf-8').catch((err) => { + if (err.code === 'ENOENT') return null; + throw err; + }); + if (raw == null) { + console.log(`📄 ${PROVIDERS_REL_PATH} not present — skipping (fresh install seeds Kimi from data.reference without the dead flags)`); + return; + } + + let config; + try { + config = JSON.parse(raw); + } catch (err) { + console.log(`⚠️ ${PROVIDERS_REL_PATH}: invalid JSON, skipping (${err.message})`); + return; + } + + let changed = 0; + for (const id of TARGET_IDS) { + const provider = config?.providers?.[id]; + if (!provider || !Array.isArray(provider.args)) continue; + const kept = provider.args.filter((arg) => !DEAD_FLAGS.has(arg)); + if (kept.length === provider.args.length) continue; + const dropped = provider.args.filter((arg) => DEAD_FLAGS.has(arg)); + provider.args = kept; + changed++; + console.log(`📝 ${PROVIDERS_REL_PATH}: ${id} dropped ${dropped.join(' ')} (not real kimi flags)`); + } + + if (changed === 0) { + console.log(`✅ ${PROVIDERS_REL_PATH}: Kimi providers carry no dead flags — no change`); + return; + } + + await writeFile(providersPath, `${JSON.stringify(config, null, 2)}\n`); + }, +}; diff --git a/scripts/migrations/269-kimi-drop-nonexistent-flags.test.js b/scripts/migrations/269-kimi-drop-nonexistent-flags.test.js new file mode 100644 index 0000000000..fc3e551f10 --- /dev/null +++ b/scripts/migrations/269-kimi-drop-nonexistent-flags.test.js @@ -0,0 +1,142 @@ +/** + * Test for migration 269 — strip the non-existent `--print` / `--afk` flags from + * the stored Kimi Code providers (issue #4139). Picked up by + * server/vitest.config.js's `../scripts/**\/*.test.js` glob. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import migration from './269-kimi-drop-nonexistent-flags.js'; + +const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); +const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8')); + +const kimiCli = (overrides = {}) => ({ + id: 'kimi-cli', + name: 'Kimi Code CLI', + type: 'cli', + command: 'kimi', + args: ['--print'], + enabled: false, + ...overrides, +}); + +const kimiTui = (overrides = {}) => ({ + id: 'kimi-tui', + name: 'Kimi Code TUI', + type: 'tui', + command: 'kimi', + args: ['--yolo'], + enabled: false, + ...overrides, +}); + +describe('migration 269 — drop non-existent kimi flags', () => { + let rootDir; + let providersPath; + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), 'migration-269-')); + mkdirSync(join(rootDir, 'data'), { recursive: true }); + providersPath = join(rootDir, 'data/providers.json'); + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + it('empties the seeded `--print` args on kimi-cli', async () => { + writeJson(providersPath, { providers: { 'kimi-cli': kimiCli() } }); + + await migration.up({ rootDir }); + + const after = readJson(providersPath).providers['kimi-cli']; + expect(after.args).toEqual([]); + // unrelated fields preserved + expect(after.command).toBe('kimi'); + expect(after.enabled).toBe(false); + }); + + it('strips the dead flags out of a curated list and keeps the rest in order', async () => { + writeJson(providersPath, { + providers: { + 'kimi-cli': kimiCli({ args: ['--print', '--model', 'kimi-k2', '--afk'] }), + }, + }); + + await migration.up({ rootDir }); + + expect(readJson(providersPath).providers['kimi-cli'].args).toEqual(['--model', 'kimi-k2']); + }); + + it('strips a user-pinned `--afk` from kimi-tui but leaves --yolo', async () => { + writeJson(providersPath, { providers: { 'kimi-tui': kimiTui({ args: ['--yolo', '--afk'] }) } }); + + await migration.up({ rootDir }); + + expect(readJson(providersPath).providers['kimi-tui'].args).toEqual(['--yolo']); + }); + + it('is a no-op when the Kimi providers are already clean', async () => { + writeJson(providersPath, { + providers: { 'kimi-cli': kimiCli({ args: [] }), 'kimi-tui': kimiTui() }, + }); + const before = readFileSync(providersPath, 'utf-8'); + + await migration.up({ rootDir }); + + expect(readFileSync(providersPath, 'utf-8')).toBe(before); + }); + + it('does not touch other providers that legitimately use --print', async () => { + writeJson(providersPath, { + providers: { + 'kimi-cli': kimiCli(), + 'claude-code': { id: 'claude-code', type: 'cli', command: 'claude', args: ['--print'] }, + 'antigravity': { id: 'antigravity', type: 'cli', command: 'agy', args: ['--print', '--dangerously-skip-permissions'] }, + }, + }); + + await migration.up({ rootDir }); + + const out = readJson(providersPath).providers; + expect(out['kimi-cli'].args).toEqual([]); + expect(out['claude-code'].args).toEqual(['--print']); + expect(out['antigravity'].args).toEqual(['--print', '--dangerously-skip-permissions']); + }); + + it('is a no-op when no kimi provider is present', async () => { + writeJson(providersPath, { providers: { 'codex': { id: 'codex', args: [] } } }); + const before = readFileSync(providersPath, 'utf-8'); + + await migration.up({ rootDir }); + + expect(readFileSync(providersPath, 'utf-8')).toBe(before); + }); + + it('tolerates a kimi provider with a non-array args field', async () => { + writeJson(providersPath, { providers: { 'kimi-cli': kimiCli({ args: null }) } }); + const before = readFileSync(providersPath, 'utf-8'); + + await migration.up({ rootDir }); + + expect(readFileSync(providersPath, 'utf-8')).toBe(before); + }); + + it('is a no-op when data/providers.json does not exist (fresh install)', async () => { + await migration.up({ rootDir }); + + expect(existsSync(providersPath)).toBe(false); + }); + + it('does not modify the file on invalid JSON (logs a warning and skips)', async () => { + writeFileSync(providersPath, '{ not valid json'); + const before = readFileSync(providersPath, 'utf-8'); + + await migration.up({ rootDir }); + + expect(readFileSync(providersPath, 'utf-8')).toBe(before); + }); +}); diff --git a/server/lib/README.md b/server/lib/README.md index 3da4ecb4c2..a482c9447a 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -133,7 +133,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `cursor.js` | Cursor Agent (`cursor-agent`) provider helpers — the `CURSOR_COMMAND` binary constant, the `isCursorCommand` predicate, and the `ensureCursorHeadlessArgs` (`--print --force`) / `ensureCursorTuiArgs` (`--force`) argv builders. `--force` is load-bearing beyond approvals: it also clears cursor's workspace-trust gate, which otherwise EXITS a headless run before any work happens. The prompt rides raw stdin (like claude/codex, unlike grok/kimi), and cursor needs no configured-default sentinel — its `auto` router is a real model id passed straight to `--model`. | | `grok.js` | xAI Grok Build (`grok`) provider helpers — id/endpoint constants (`GROK_API_ID`/`GROK_CLI_ID`/`GROK_TUI_ID`/`GROK_API_ENDPOINT`), `isGrokCommand`/`isGrokCliProvider`/`isGrokTuiProvider` predicates, `ensureGrokHeadlessArgs`/`ensureGrokTuiArgs` argv builders (grok reads its prompt from `--prompt-file /dev/stdin`, not raw stdin; model selection uses the `GROK_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Antigravity), and `prepareGrokPromptFile` (Windows temp-file delivery fallback). | | `grokVideoClip.js` | Clip lengths grok's `image_to_video` actually delivers — `GROK_VIDEO_DURATIONS` (`[6, 10]`, measured in #3022: a 2s/3s request returns the same 6.04s clip), `GROK_VIDEO_DEFAULT_DURATION`, `resolveGrokDuration()` (validate an already-grok-shaped request, else default), and `nearestGrokDuration()` (round a length authored against another backend's continuous contract UP to the shortest clip that covers it — for translating a CD/commission `targetDurationSeconds`). Dependency-free so `validation.js` / `routes/videoGen.js` can derive their schemas from the same list the services gate on. | -| `kimi.js` | Moonshot AI Kimi Code (`kimi`) provider helpers — id constants (`KIMI_CLI_ID`/`KIMI_TUI_ID`), `isKimiCommand`/`isKimiCliProvider`/`isKimiTuiProvider` predicates, `ensureKimiHeadlessArgs` (`--print`, implies `--afk`) / `ensureKimiTuiArgs` (`--yolo`) argv builders (model selection uses the `KIMI_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Grok/Antigravity), and `prepareKimiPrompt` (delivers the prompt as the `--prompt ` argv, not stdin). | +| `kimi.js` | Moonshot AI Kimi Code (`kimi`) provider helpers — id constants (`KIMI_CLI_ID`/`KIMI_TUI_ID`), `isKimiCommand`/`isKimiCliProvider`/`isKimiTuiProvider` predicates, `ensureKimiHeadlessArgs` (model flag only — non-interactive mode is implicit in `--prompt`, and kimi has no `--print`/`--afk` and refuses approval flags alongside `--prompt`) / `ensureKimiTuiArgs` (`--yolo`) argv builders (model selection uses the `KIMI_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Grok/Antigravity), and `prepareKimiPrompt` (delivers the prompt as the `--prompt ` argv, not stdin). | | `runners.js` | Image-runner family constants (`RUNNER_FAMILIES`, `loraCompatKey`), the separate `VIDEO_LORA_FAMILIES` + `videoLoraFamily` used by the video LoRA picker, and `isMiniMaxH3Runtime` / `MINIMAX_H3_RUNTIMES` — the predicate every gate asserting a MiniMax H3 model fact (24 fps, joint A/V, CFG-distilled, the 17n+5 grid) must use so the MLX and CUDA runtimes cannot drift apart. Mirrored to `client/src/lib/runnerFamilies.js`. | | `codexAssistantExtract.js` | Strip Codex CLI banner + echoed metadata from session transcript. | | `codexCliOutput.js` | Network/system error patterns for `agentErrorAnalysis.js`. | diff --git a/server/lib/aiToolkit/defaults/providers.sample.json b/server/lib/aiToolkit/defaults/providers.sample.json index b93aa563e9..c6cf0270ad 100644 --- a/server/lib/aiToolkit/defaults/providers.sample.json +++ b/server/lib/aiToolkit/defaults/providers.sample.json @@ -338,7 +338,7 @@ "name": "Kimi Code CLI", "type": "cli", "command": "kimi", - "args": ["--print"], + "args": [], "models": ["kimi-configured-default"], "defaultModel": "kimi-configured-default", "lightModel": "kimi-configured-default", diff --git a/server/lib/cliProviderArgs.js b/server/lib/cliProviderArgs.js index 28fb74b319..e0b0560dc9 100644 --- a/server/lib/cliProviderArgs.js +++ b/server/lib/cliProviderArgs.js @@ -14,7 +14,7 @@ * - Antigravity: `agy --print ` (argv value, not stdin; + `--model`) * - Gemini CLI: legacy prompt piped to stdin (+ `-m `) * - Grok Build: `grok --prompt-file /dev/stdin` (+ `--model `, see grok.js) - * - Kimi Code: `kimi --print --prompt ` (argv value, not stdin; see kimi.js) + * - Kimi Code: `kimi --prompt ` (argv value, not stdin; see kimi.js) * - Cursor: `cursor-agent --print --force` (prompt on stdin; see cursor.js) * - Claude Code: `-p -` (+ `--model `) */ @@ -66,7 +66,8 @@ export function buildCliArgs(provider) { * - Antigravity (`agy`): the prompt is spliced in as the VALUE of --print * (agy does NOT read stdin) → `useStdin: false`. * - Kimi (`kimi`): the prompt is spliced in as the VALUE of --prompt - * (kimi does NOT read stdin in --print mode) → `useStdin: false`. + * (kimi does NOT read stdin; supplying --prompt is also what selects + * non-interactive mode) → `useStdin: false`. * - Grok on Windows: the `/dev/stdin` prompt-file is rewritten to a temp file * → `useStdin: false` with a real `cleanup`. * - Every other provider (Claude Code `-p -`, Codex `exec -`, OpenCode `run`, diff --git a/server/lib/cliProviderArgs.test.js b/server/lib/cliProviderArgs.test.js index d14464911c..c6b16e2f06 100644 --- a/server/lib/cliProviderArgs.test.js +++ b/server/lib/cliProviderArgs.test.js @@ -162,33 +162,34 @@ describe('cliProviderArgs', () => { }); describe('buildCliArgs — Kimi Code CLI', () => { - it('builds a headless --print invocation without --model for the sentinel (seeded args)', () => { - const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--print'], defaultModel: 'kimi-configured-default' }); - expect(args).toEqual(['--print']); + it('builds an empty headless argv for the sentinel (seeded args) — no mode flag exists (#4139)', () => { + const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-configured-default' }); + expect(args).toEqual([]); expect(args).not.toContain('--model'); expect(args).not.toContain('kimi-configured-default'); }); - it('adds --print when the saved args omit it', () => { - const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-configured-default' }); - expect(args).toEqual(['--print']); + it('never injects --print or --afk — kimi rejects both outright (#4139)', () => { + const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-k2' }); + expect(args).not.toContain('--print'); + expect(args).not.toContain('--afk'); }); it('injects --model when a concrete model id is set (path/exe tolerant)', () => { - const args = buildCliArgs({ id: 'my-kimi', command: '/opt/homebrew/bin/kimi', args: ['--print'], defaultModel: 'kimi-k2' }); - expect(args).toEqual(['--print', '--model', 'kimi-k2']); + const args = buildCliArgs({ id: 'my-kimi', command: '/opt/homebrew/bin/kimi', args: [], defaultModel: 'kimi-k2' }); + expect(args).toEqual(['--model', 'kimi-k2']); }); it('respects a user-baked --model and does not duplicate it', () => { - const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--print', '--model', 'mine'], defaultModel: 'kimi-configured-default' }); + const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--model', 'mine'], defaultModel: 'kimi-configured-default' }); expect(args.filter((a) => a === '--model')).toHaveLength(1); expect(args).toContain('mine'); }); it('delivers the prompt as the --prompt argv value (useStdin false)', () => { - const built = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--print'], defaultModel: 'kimi-configured-default' }); + const built = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-configured-default' }); const { args, useStdin } = prepareCliPrompt('kimi', built, 'write a haiku'); - expect(args).toEqual(['--print', '--prompt', 'write a haiku']); + expect(args).toEqual(['--prompt', 'write a haiku']); expect(useStdin).toBe(false); }); }); diff --git a/server/lib/kimi.js b/server/lib/kimi.js index 6693d1e633..fa08b87f4d 100644 --- a/server/lib/kimi.js +++ b/server/lib/kimi.js @@ -3,17 +3,26 @@ * * Kimi Code (MoonshotAI/kimi-cli, MIT-licensed) ships two PortOS process-provider * shapes (the plain HTTP API entry already exists separately as `nvidia-kimi`): - * - `kimi-cli` (type `cli`) — headless one-shot via `kimi --print`. + * - `kimi-cli` (type `cli`) — headless one-shot via `kimi --prompt `. * - `kimi-tui` (type `tui`) — the interactive Kimi Code TUI driven over a PTY. * - * Prompt delivery (headless): unlike claude/codex (raw stdin), `kimi --print` - * takes the prompt as the VALUE of its `--prompt`/`-p` flag and does NOT read - * stdin. `--print` also implicitly enables `--afk` (away-from-keyboard: auto- - * approve tool calls, auto-dismiss AskUserQuestion), so a headless run never - * stalls on an approval prompt. `prepareKimiPrompt` splices the prompt in as the - * `--prompt` value and reports `useStdin: false`, mirroring the antigravity - * `{ args, useStdin, cleanup }` shape so the shared `prepareCliPrompt` dispatcher - * can handle it uniformly. + * Verified against a live `kimi` v0.32.0 (issue #4139 — the shape below was first + * written blind against docs, and every headless run failed at argv parsing): + * - There is NO `--print` flag (`error: unknown option '--print'`). Non-interactive + * mode is implicit in supplying `-p`/`--prompt`, so the headless argv needs no + * mode flag at all beyond the prompt itself. + * - There is NO `--afk` flag either (`error: unknown option '--afk'`). + * - The headless path takes NO approval-posture flag: kimi refuses to combine + * `--prompt` with `--yolo`/`-y`/`--auto` (`error: Cannot combine --prompt with + * --yolo.`) and runs unattended without one. Only the interactive TUI path + * (no `--prompt`) gets `--yolo`. + * + * Prompt delivery (headless): unlike claude/codex (raw stdin), `kimi` takes the + * prompt as the VALUE of its `--prompt`/`-p` flag and does NOT read stdin (neither + * a stdin path nor a `--prompt-file` option appears in `--help`). + * `prepareKimiPrompt` splices the prompt in as the `--prompt` value and reports + * `useStdin: false`, mirroring the antigravity `{ args, useStdin, cleanup }` shape + * so the shared `prepareCliPrompt` dispatcher can handle it uniformly. * * Model selection mirrors Antigravity/Grok Build: PortOS does not pick a model. * The stored sentinel lives in providerModels.js (`KIMI_CONFIGURED_DEFAULT`); @@ -24,26 +33,15 @@ * Dependency-light on purpose: imports only `providerModels.js` helpers, mirroring * `grok.js`/`antigravity.js` so it stays importable from the standalone autofixer. * - * NOTE: `kimi` was not installed in the dev environment where this shipped, so the - * argv-value prompt path (`--prompt `, like `agy --print `) was - * chosen as the documented default and should be confirmed against a live binary. - * Two follow-ups to reconcile once a live `kimi` is available (raised in review, - * deferred because they can't be validated blind and both risk regressing the - * happy path if guessed wrong): - * 1. Argv length limits. A large CoS operating-contract prompt on the argv can - * exceed Windows' ~32K command-line limit (and eventually POSIX ARG_MAX). If - * the live `kimi --print` accepts the prompt from stdin (or from a - * `--prompt-file ` that can point at `/dev/stdin`, as grok does), switch - * this delivery to stdin to lift the cap. It is NOT switched now because the - * antigravity analog (`agy --print`) takes the prompt as an argv VALUE and - * does NOT read stdin at all — guessing stdin against an agy-like `kimi` would - * silently deliver an empty prompt, a worse failure than the length ceiling. - * 2. Structured-output contamination. If plain `--print` interleaves intermediate - * tool/assistant activity with the final message, a pipeline stage that parses - * stdout as JSON could choke on the chatter. If the live `kimi` exposes a - * "final message only" flag, add it to `ensureKimiHeadlessArgs`. It is NOT - * added now because passing a flag the binary doesn't recognize would make - * every headless run fail at startup. + * Known remaining limitation (confirmed, not a guess): argv length. A large CoS + * operating-contract prompt rides the command line and can exceed Windows' ~32K + * limit (and eventually POSIX `ARG_MAX`). There is no lower-risk delivery to switch + * to — `-p ` is the only prompt mechanism `kimi --help` documents. + * + * Deliberately not adopted: `--output-format stream-json` (`--output-format` takes + * `text` (default) or `stream-json`). It would let a pipeline stage parse discrete + * JSON events instead of scraping possibly-interleaved plain text, but no stage + * parses kimi's stdout programmatically today, so `text` stays the default. */ import { argvHasFlag, commandBasename, hasModelFlag } from './providerModels.js'; @@ -59,14 +57,15 @@ const isFlagToken = (a) => typeof a === 'string' && a.startsWith('-'); export const KIMI_CLI_ID = 'kimi-cli'; export const KIMI_TUI_ID = 'kimi-tui'; -// `--print` puts kimi in non-interactive print mode (implies `--afk`). -const PRINT_FLAGS = ['--print']; -// The prompt-carrying flags — kimi reads the prompt as this flag's VALUE. +// The prompt-carrying flags — kimi reads the prompt as this flag's VALUE, and +// their mere presence is what puts kimi in non-interactive mode (there is no +// separate `--print`-style boolean; see the header note). const PROMPT_FLAGS = ['--prompt', '-p']; // Auto-approval postures for the unattended PTY: `--yolo`/`-y` auto-approve all -// tool calls; `--afk` also auto-dismisses AskUserQuestion. Any one already -// present means the user pinned their own posture — don't add another. -const APPROVAL_FLAGS = ['--yolo', '-y', '--afk']; +// tool calls. Either one already present means the user pinned their own posture +// — don't add another. Interactive path ONLY: kimi rejects these alongside +// `--prompt` (`Cannot combine --prompt with --yolo.`). +const APPROVAL_FLAGS = ['--yolo', '-y']; /** * True when a provider command points at the Kimi Code binary — the bare `kimi` @@ -92,12 +91,15 @@ export function isKimiTuiProvider(provider) { } /** - * Build the headless (one-shot) argv for the Kimi Code CLI. Ensures, when not - * already pinned by the user's saved `args`: - * - `--print` — non-interactive print mode (implies `--afk`, so tool calls - * auto-approve; PortOS parses stdout as plain text). - * - `--model ` — gated on `model` being a real id (the sentinel already - * resolved to null upstream) AND no user-baked model flag. + * Build the headless (one-shot) argv for the Kimi Code CLI. The ONLY thing added + * here is `--model `, gated on `model` being a real id (the sentinel already + * resolved to null upstream) AND no user-baked model flag. + * + * No mode flag and no approval flag are added — kimi has neither a `--print` + * boolean (non-interactive mode is implicit in `--prompt`) nor a headless + * approval posture (it refuses `--yolo`/`-y`/`--auto` alongside `--prompt`). + * Adding any of them makes the binary exit at argv parsing before it runs. + * * The prompt itself is NOT added here — it's spliced in as the `--prompt` value * at spawn time by `prepareKimiPrompt`. * @param {string[]} baseArgs - user/legacy args (already model-flag-sanitized) @@ -106,9 +108,6 @@ export function isKimiTuiProvider(provider) { */ export function ensureKimiHeadlessArgs(baseArgs = [], model) { const out = [...baseArgs]; - if (!argvHasFlag(out, PRINT_FLAGS)) { - out.push('--print'); - } if (model && !hasModelFlag(out)) { out.push('--model', model); } @@ -121,6 +120,7 @@ export function ensureKimiHeadlessArgs(baseArgs = [], model) { * `--dangerously-bypass-approvals-and-sandbox` / claude-code-tui * `--dangerously-skip-permissions` / grok `--permission-mode bypassPermissions` * TUI defaults). Idempotent when the user already pinned an approval posture. + * Interactive path only — the headless (`--prompt`) path must never get one. * @param {string[]} args * @returns {string[]} */ @@ -134,7 +134,8 @@ export function ensureKimiTuiArgs(args = []) { /** * Spawn-time prompt delivery for the Kimi Code CLI: splice the prompt in as the - * VALUE of the `--prompt` flag (kimi does NOT read stdin in `--print` mode). + * VALUE of the `--prompt` flag (kimi does NOT read stdin). Supplying the flag is + * also what selects non-interactive mode — there is no separate mode flag. * Mirrors the `{ args, useStdin, cleanup }` shape of * `antigravity.js#prepareAntigravityPrompt` / `grok.js#prepareGrokPromptFile` so * the spawn sites can dispatch through the single `prepareCliPrompt` helper. diff --git a/server/lib/kimi.test.js b/server/lib/kimi.test.js index 7e6fe18751..f15aea2571 100644 --- a/server/lib/kimi.test.js +++ b/server/lib/kimi.test.js @@ -41,20 +41,35 @@ describe('kimi.js', () => { }); describe('ensureKimiHeadlessArgs', () => { - it('adds --print when absent', () => { - expect(ensureKimiHeadlessArgs([])).toEqual(['--print']); + it('adds nothing to an empty argv — non-interactive mode is implicit in --prompt (#4139)', () => { + expect(ensureKimiHeadlessArgs([])).toEqual([]); }); - it('does not double-add --print when already present (seeded default)', () => { - expect(ensureKimiHeadlessArgs(['--print'])).toEqual(['--print']); + it('passes user args through untouched when no model is pinned', () => { + expect(ensureKimiHeadlessArgs(['--output-format', 'stream-json'])) + .toEqual(['--output-format', 'stream-json']); }); it('injects --model only for a real (non-null) model id', () => { - expect(ensureKimiHeadlessArgs(['--print'], 'kimi-k2')).toEqual(['--print', '--model', 'kimi-k2']); - expect(ensureKimiHeadlessArgs(['--print'], null)).toEqual(['--print']); - expect(ensureKimiHeadlessArgs(['--print'], '')).toEqual(['--print']); + expect(ensureKimiHeadlessArgs([], 'kimi-k2')).toEqual(['--model', 'kimi-k2']); + expect(ensureKimiHeadlessArgs([], null)).toEqual([]); + expect(ensureKimiHeadlessArgs([], '')).toEqual([]); }); it('does not duplicate a user-baked model flag', () => { - expect(ensureKimiHeadlessArgs(['--print', '--model', 'mine'], 'other')).toEqual(['--print', '--model', 'mine']); - expect(ensureKimiHeadlessArgs(['-m', 'mine'], 'other')).toEqual(['-m', 'mine', '--print']); + expect(ensureKimiHeadlessArgs(['--model', 'mine'], 'other')).toEqual(['--model', 'mine']); + expect(ensureKimiHeadlessArgs(['-m', 'mine'], 'other')).toEqual(['-m', 'mine']); + }); + // Regression guard for #4139: a live kimi v0.32.0 exits at argv parsing on any + // of these — `--print`/`--afk` are not options at all, and `--yolo`/`-y`/`--auto` + // are refused alongside `--prompt` ("Cannot combine --prompt with --yolo."). + it('never injects a flag the headless binary rejects', () => { + const forbidden = ['--print', '--afk', '--yolo', '-y', '--auto']; + for (const args of [[], ['--model', 'mine'], ['-p'], ['--prompt', 'x']]) { + for (const model of [null, undefined, '', 'kimi-k2']) { + const out = ensureKimiHeadlessArgs(args, model); + for (const flag of forbidden) { + expect(out.filter((a) => a === flag)).toEqual(args.filter((a) => a === flag)); + } + } + } }); }); @@ -65,44 +80,50 @@ describe('kimi.js', () => { it('is idempotent when --yolo is already present (seeded default)', () => { expect(ensureKimiTuiArgs(['--yolo'])).toEqual(['--yolo']); }); - it('respects a user-pinned approval posture (-y / --afk)', () => { + it('respects a user-pinned -y short posture', () => { expect(ensureKimiTuiArgs(['-y'])).toEqual(['-y']); - expect(ensureKimiTuiArgs(['--afk'])).toEqual(['--afk']); + }); + it('still adds --yolo alongside a stale --afk (not a real kimi flag, #4139)', () => { + expect(ensureKimiTuiArgs(['--afk'])).toEqual(['--afk', '--yolo']); }); }); describe('prepareKimiPrompt', () => { it('appends the prompt as the --prompt value, useStdin false', () => { - const { args, useStdin, cleanup } = prepareKimiPrompt(['--print'], 'do the thing'); - expect(args).toEqual(['--print', '--prompt', 'do the thing']); + const { args, useStdin, cleanup } = prepareKimiPrompt([], 'do the thing'); + expect(args).toEqual(['--prompt', 'do the thing']); expect(useStdin).toBe(false); expect(typeof cleanup).toBe('function'); }); + it('appends after unrelated user args', () => { + const { args } = prepareKimiPrompt(['--model', 'kimi-k2'], 'do the thing'); + expect(args).toEqual(['--model', 'kimi-k2', '--prompt', 'do the thing']); + }); it('splices the value after a user-baked prompt flag', () => { - const { args } = prepareKimiPrompt(['--print', '--prompt'], 'task'); - expect(args).toEqual(['--print', '--prompt', 'task']); + const { args } = prepareKimiPrompt(['--prompt'], 'task'); + expect(args).toEqual(['--prompt', 'task']); }); it('splices after the short -p flag', () => { const { args } = prepareKimiPrompt(['-p'], 'task'); expect(args).toEqual(['-p', 'task']); }); it('coerces a non-string prompt to empty', () => { - const { args } = prepareKimiPrompt(['--print'], undefined); - expect(args).toEqual(['--print', '--prompt', '']); + const { args } = prepareKimiPrompt([], undefined); + expect(args).toEqual(['--prompt', '']); }); it('REPLACES a user-baked separated prompt value instead of leaving it a stray positional (#2815)', () => { - // ['--print','--prompt','old'] must NOT become ['--print','--prompt','task','old'] — - // the trailing 'old' would reach kimi as a second, positional prompt. - const { args } = prepareKimiPrompt(['--print', '--prompt', 'old'], 'task'); - expect(args).toEqual(['--print', '--prompt', 'task']); + // ['--prompt','old'] must NOT become ['--prompt','task','old'] — the trailing + // 'old' would reach kimi as a second, positional prompt. + const { args } = prepareKimiPrompt(['--prompt', 'old'], 'task'); + expect(args).toEqual(['--prompt', 'task']); }); it('replaces a baked -p short-flag value', () => { - const { args } = prepareKimiPrompt(['-p', 'old', '--print'], 'task'); - expect(args).toEqual(['-p', 'task', '--print']); + const { args } = prepareKimiPrompt(['-p', 'old', '--model', 'kimi-k2'], 'task'); + expect(args).toEqual(['-p', 'task', '--model', 'kimi-k2']); }); it('replaces the value of a joined --prompt=old form (#2815)', () => { - const { args } = prepareKimiPrompt(['--print', '--prompt=old'], 'task'); - expect(args).toEqual(['--print', '--prompt=task']); + const { args } = prepareKimiPrompt(['--prompt=old'], 'task'); + expect(args).toEqual(['--prompt=task']); }); it('replaces a joined -p=old short form', () => { const { args } = prepareKimiPrompt(['-p=old'], 'task'); @@ -111,12 +132,31 @@ describe('kimi.js', () => { it('inserts a value after a trailing bare flag followed by another flag', () => { // --prompt is immediately followed by another flag, so it has no value yet; // insert (not replace) so the following flag is preserved. - const { args } = prepareKimiPrompt(['--prompt', '--print'], 'task'); - expect(args).toEqual(['--prompt', 'task', '--print']); + const { args } = prepareKimiPrompt(['--prompt', '--model'], 'task'); + expect(args).toEqual(['--prompt', 'task', '--model']); }); it('uses the LAST prompt flag when more than one is baked in', () => { const { args } = prepareKimiPrompt(['--prompt', 'a', '-p', 'b'], 'task'); expect(args).toEqual(['--prompt', 'a', '-p', 'task']); }); }); + + // The full headless argv as a spawn site assembles it, spelled out end to end + // so a regression can't hide behind two individually-plausible halves (#4139). + describe('headless argv, end to end', () => { + const headlessArgv = (baseArgs, model, prompt) => + prepareKimiPrompt(ensureKimiHeadlessArgs(baseArgs, model), prompt).args; + + it('is just the prompt pair for the shipped (empty) provider args', () => { + expect(headlessArgv([], null, 'summarize the diff')).toEqual(['--prompt', 'summarize the diff']); + }); + it('carries a pinned model ahead of the prompt pair', () => { + expect(headlessArgv([], 'kimi-k2', 'summarize the diff')) + .toEqual(['--model', 'kimi-k2', '--prompt', 'summarize the diff']); + }); + it('drops nothing a user pinned themselves', () => { + expect(headlessArgv(['--output-format', 'stream-json'], null, 'go')) + .toEqual(['--output-format', 'stream-json', '--prompt', 'go']); + }); + }); }); diff --git a/server/lib/tuiHandshake.test.js b/server/lib/tuiHandshake.test.js index 38b4f0df2d..4cd0ed2b09 100644 --- a/server/lib/tuiHandshake.test.js +++ b/server/lib/tuiHandshake.test.js @@ -896,8 +896,8 @@ describe('tuiHandshake.applyCommandDefaults', () => { expect(applyCommandDefaults('kimi', [])).toEqual(['--yolo']); // Seeded default already carries --yolo — no duplicate. expect(applyCommandDefaults('kimi', ['--yolo'])).toEqual(['--yolo']); - // A user-pinned posture (-y / --afk) is respected. - expect(applyCommandDefaults('kimi', ['--afk'])).toEqual(['--afk']); + // A user-pinned short posture is respected. + expect(applyCommandDefaults('kimi', ['-y'])).toEqual(['-y']); }); });