fix(daemon): pre-flight DeepSeek prompts against Windows .cmd-shim quoting

The first-pass argv-byte guard only inspects the raw composed prompt, so
on Windows an npm-installed `deepseek` resolves to a `.cmd` shim and the
spawn path then wraps the call in `cmd.exe /d /s /c "<inner>"` with
every embedded `"` doubled by `quoteWindowsCommandArg`. A quote-heavy
prompt (code blocks, JSON-shaped skill seeds) under the 30,000-byte
budget can therefore still expand past CreateProcess's 32_767-char
`lpCommandLine` cap and surface as a generic spawn ENAMETOOLONG instead
of the DeepSeek-named, actionable `AGENT_PROMPT_TOO_LARGE` the budget
guard was meant to provide. Add a second pure helper
`checkWindowsCmdShimCommandLineBudget(def, resolvedBin, args)` that
mirrors the platform layer's per-arg quoting and recomputes the would-be
command line length whenever the resolved binary is a `.cmd` / `.bat`
shim, and call it from `/api/chat` after `buildArgs` / `resolveAgentBin`
so the same SSE error fires before `spawn`. Pin the new path with a
quote-heavy regression (prompt is under the byte budget but doubles
past the kernel cap) plus no-op tests for non-`.cmd` resolutions, null
bin, and stdin-only adapters so the guard can't drift back.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
This commit is contained in:
pftom
2026-05-04 20:12:25 +08:00
parent 27baebd0c1
commit 901136190d
4 changed files with 193 additions and 1 deletions
+63
View File
@@ -879,6 +879,69 @@ export function checkPromptArgvBudget(def, composed) {
};
}
// Mirror of packages/platform's `quoteWindowsCommandArg`, kept local so
// `checkWindowsCmdShimCommandLineBudget` can run on macOS/Linux against
// a fake `.cmd` path in tests without forking on `process.platform`.
// Must stay byte-for-byte identical to the platform copy — the helper's
// whole point is to compute the exact `cmd.exe /d /s /c "<inner>"` line
// the spawn path will produce on Windows.
function quoteForWindowsCmdShim(value) {
const str = String(value ?? '');
if (!/[\s"&<>|^]/.test(str)) return str;
return `"${str.replace(/"/g, '""')}"`;
}
// Windows' CreateProcess caps `lpCommandLine` at 32_767 chars. Going
// through a `.cmd` / `.bat` shim adds a `cmd.exe /d /s /c "<inner>"`
// wrapper, and `quoteForWindowsCmdShim` doubles every embedded `"` plus
// wraps any whitespace/special-char arg in outer quotes — so a prompt
// well under `maxPromptArgBytes` can still expand past the kernel cap
// once it's run through the shim. Leave headroom for any per-CLI flag
// the adapter might tack on at exec time and for cmd.exe's own framing.
const WINDOWS_CREATE_PROCESS_LIMIT = 32_767;
const WINDOWS_CREATE_PROCESS_HEADROOM = 256;
// Post-buildArgs guard for argv-bound adapters whose binary resolves to
// a Windows `.cmd` / `.bat` shim. Computes the exact command line shape
// `createCommandInvocation` (in packages/platform) hands to `spawn` —
// `cmd.exe /d /s /c "<quoted command + quoted args>"` — and refuses the
// run when that line would exceed the CreateProcess limit (less a small
// headroom). Returns the same `AGENT_PROMPT_TOO_LARGE` shape as
// `checkPromptArgvBudget` so the SSE error path in `/api/chat` doesn't
// have to special-case it.
//
// No-op when:
// - the adapter doesn't declare `maxPromptArgBytes` (stdin adapters
// never go through this path);
// - the resolved binary isn't a `.cmd` / `.bat` (POSIX hosts and
// direct `.exe` resolutions on Windows skip the cmd.exe wrap);
// - the assembled line fits comfortably under the kernel cap.
//
// Pure: takes `resolvedBin` explicitly so a test on macOS can pass a
// fake `C:\\…\\deepseek.cmd` path and exercise the same math the daemon
// would run on Windows.
export function checkWindowsCmdShimCommandLineBudget(def, resolvedBin, args) {
if (!def || typeof def.maxPromptArgBytes !== 'number') return null;
if (typeof resolvedBin !== 'string' || !/\.(bat|cmd)$/i.test(resolvedBin)) return null;
const argList = Array.isArray(args) ? args : [];
const inner = [resolvedBin, ...argList].map(quoteForWindowsCmdShim).join(' ');
// `cmd.exe /d /s /c "<inner>"` — same shape as buildCmdShimInvocation
// in packages/platform; the leading 'cmd.exe ' + '/d /s /c ' framing
// plus the two outer quote chars rounds out the full command line.
const commandLineLength = 'cmd.exe /d /s /c '.length + inner.length + 2;
const safeLimit = WINDOWS_CREATE_PROCESS_LIMIT - WINDOWS_CREATE_PROCESS_HEADROOM;
if (commandLineLength <= safeLimit) return null;
return {
code: 'AGENT_PROMPT_TOO_LARGE',
message:
`${def.name} on Windows runs through a .cmd shim and this run's prompt would expand past the CreateProcess command-line limit ` +
`after cmd.exe quote-doubling (${commandLineLength} > ${safeLimit} chars). ` +
'Reduce quote-heavy content in the selected skills/design-system context, shorten the conversation, or pick an adapter with stdin support.',
commandLineLength,
limit: safeLimit,
};
}
// Resolve the absolute path of an agent's binary on the current PATH.
// Used by the chat handler so spawn() gets the same executable that
// detection reported as available — fixes Windows ENOENT when the bare
+29
View File
@@ -11,6 +11,7 @@ import { composeSystemPrompt } from './prompts/system.js';
import { createCommandInvocation } from '@open-design/platform';
import {
checkPromptArgvBudget,
checkWindowsCmdShimCommandLineBudget,
detectAgents,
getAgentDef,
isKnownModel,
@@ -2536,6 +2537,34 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
{ cwd: effectiveCwd },
);
// Second-pass budget check that knows about the Windows `.cmd` shim
// wrap. The pre-buildArgs `checkPromptArgvBudget` only looks at the
// raw composed prompt; on Windows an npm-installed adapter resolves
// to e.g. `deepseek.cmd`, the spawn path goes through `cmd.exe /d /s
// /c "<inner>"`, and `quoteForWindowsCmdShim` doubles every embedded
// `"` plus wraps any whitespace/special-char arg in outer quotes —
// so a quote-heavy prompt that fit under `maxPromptArgBytes` can
// still expand past CreateProcess's 32_767-char cap. Fail fast with
// the same `AGENT_PROMPT_TOO_LARGE` shape so the SSE error path
// doesn't have to special-case it.
const cmdShimBudgetError = checkWindowsCmdShimCommandLineBudget(
def,
resolvedBin,
args,
);
if (cmdShimBudgetError) {
design.runs.emit(
run,
'error',
createSseErrorPayload(
cmdShimBudgetError.code,
cmdShimBudgetError.message,
{ retryable: false },
),
);
return design.runs.finish(run, 'failed', 1, null);
}
const send = (event, data) => design.runs.emit(run, event, data);
const odMediaEnv = {
+100
View File
@@ -7,6 +7,7 @@ import { join } from 'node:path';
import {
AGENT_DEFS,
checkPromptArgvBudget,
checkWindowsCmdShimCommandLineBudget,
resolveAgentExecutable,
spawnEnvForAgent,
} from '../src/agents.js';
@@ -491,6 +492,105 @@ test('checkPromptArgvBudget is a no-op for adapters without maxPromptArgBytes',
assert.equal(checkPromptArgvBudget(claude, huge), null);
});
// On Windows an npm-installed `deepseek` resolves to a `.cmd` shim and
// the spawn path wraps the call in `cmd.exe /d /s /c "<inner>"`, with
// every embedded `"` doubled by `quoteWindowsCommandArg`. A prompt that
// fits under the raw `maxPromptArgBytes` budget but is heavy on quote
// characters (code blocks, JSON-shaped skill seeds) can therefore still
// expand past CreateProcess's 32_767-char `lpCommandLine` cap — surfacing
// as a generic spawn ENAMETOOLONG instead of the actionable DeepSeek-
// named error the budget guard was meant to provide. The post-buildArgs
// check `checkWindowsCmdShimCommandLineBudget` computes the would-be
// command line length using the same quoting math the platform layer
// uses on Windows, so a quote-heavy prompt under the byte budget still
// fails with `AGENT_PROMPT_TOO_LARGE` before spawn.
test('checkWindowsCmdShimCommandLineBudget flags quote-heavy prompts that expand past CreateProcess limit', () => {
// Prompt is *under* the raw byte budget, but ~entirely `"` chars so
// cmd.exe's quote-doubling roughly doubles its command-line cost.
const quoteHeavyPromptLength = deepseek.maxPromptArgBytes - 100;
const quoteHeavyPrompt = '"'.repeat(quoteHeavyPromptLength);
// Sanity: the raw-byte guard must let this through, otherwise the new
// post-buildArgs check would never fire on a real run.
assert.equal(
checkPromptArgvBudget(deepseek, quoteHeavyPrompt),
null,
'quote-heavy prompt under the raw byte budget must pass the pre-buildArgs guard',
);
const args = deepseek.buildArgs(quoteHeavyPrompt, [], [], {});
// Use a realistic npm-style Windows install path so the resolved-bin
// contribution mirrors a real user's environment.
const resolvedBin = 'C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd';
const flagged = checkWindowsCmdShimCommandLineBudget(deepseek, resolvedBin, args);
assert.ok(
flagged,
'quote-heavy prompt that doubles past the CreateProcess cap must trip the cmd-shim guard',
);
assert.equal(flagged.code, 'AGENT_PROMPT_TOO_LARGE');
assert.ok(
flagged.commandLineLength > flagged.limit,
`commandLineLength (${flagged.commandLineLength}) must exceed limit (${flagged.limit})`,
);
assert.ok(
flagged.limit < 32_768,
'guard must keep its safe limit strictly under the documented Windows CreateProcess cap',
);
assert.match(flagged.message, /DeepSeek/);
assert.match(flagged.message, /cmd\.exe quote-doubling/);
assert.match(flagged.message, /stdin support/);
});
test('checkWindowsCmdShimCommandLineBudget lets ordinary prompts through .cmd resolutions', () => {
// Same Windows-shim resolution path, but a plain prompt — well under
// every limit. The guard must return null so the chat happy path
// proceeds to spawn.
const args = deepseek.buildArgs('write hello world', [], [], {});
const resolvedBin = 'C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd';
assert.equal(
checkWindowsCmdShimCommandLineBudget(deepseek, resolvedBin, args),
null,
);
});
test('checkWindowsCmdShimCommandLineBudget is a no-op for non-.cmd resolutions', () => {
// POSIX hosts (and direct `.exe` resolutions on Windows) don't go
// through the cmd.exe wrap, so the would-be command line never gets
// the quote-doubling expansion. Even an oversized argv must skip this
// check — `checkPromptArgvBudget` is the one responsible for catching
// those cases.
const args = deepseek.buildArgs('x'.repeat(40_000), [], [], {});
assert.equal(
checkWindowsCmdShimCommandLineBudget(deepseek, '/usr/local/bin/deepseek', args),
null,
);
assert.equal(
checkWindowsCmdShimCommandLineBudget(
deepseek,
'C:\\Program Files\\DeepSeek\\deepseek.exe',
args,
),
null,
);
});
test('checkWindowsCmdShimCommandLineBudget no-ops when resolvedBin is null or adapter has no budget', () => {
// Bin resolution failed but the run continued long enough to reach
// this guard — must be a no-op so the existing AGENT_UNAVAILABLE path
// still fires from server.ts.
assert.equal(
checkWindowsCmdShimCommandLineBudget(deepseek, null, []),
null,
);
// Stdin-delivered adapters never declare `maxPromptArgBytes` — the
// guard must skip them even when handed a `.cmd` path.
assert.equal(
checkWindowsCmdShimCommandLineBudget(claude, 'C:\\fake\\claude.cmd', []),
null,
);
});
test('deepseek entry does not advertise deepseek-tui as a fallback bin', () => {
// `deepseek` is the dispatcher that owns `exec` / `--auto`; `deepseek-tui`
// is the runtime companion the dispatcher invokes. Upstream installs both
+1 -1
View File
@@ -201,7 +201,7 @@ The adapter declares which strategy to use via `capabilities().nativeSkillLoadin
- Streaming: plain text deltas to stdout in non-`--json` mode (tool-call notifications go to stderr). Skipping `--json` is intentional — `deepseek exec --json` batches the entire run into one trailing summary object instead of streaming, which would freeze the chat UI until end-of-turn.
- Auto-approval: `--auto` enables agentic mode with the YOLO permission posture. The daemon runs every CLI without a TTY, so the interactive approval prompt would otherwise hang the run.
- Skills: prompt injection only in v1. DeepSeek TUI does walk `.agents/skills`, `skills`, `.opencode/skills`, `.claude/skills`, and `~/.deepseek/skills` first-wins, so a future revision can switch to file-placed skill loading the same way Claude Code does.
- Prompt delivery: positional argv (no stdin sentinel; clap declares `prompt: String` as a required field). This means very large composed prompts can hit Windows' ~32 KB `CreateProcess` limit; for typical chat prompts this is non-issue. Upstream support for a `-` stdin sentinel would let us flip this to `promptViaStdin: true` like the other adapters. To avoid surfacing oversized prompts as a generic `spawn ENAMETOOLONG` / `E2BIG`, the adapter declares `maxPromptArgBytes` (currently 30,000); `/api/chat` runs the composed prompt through the `checkPromptArgvBudget` helper before bin resolution and emits an actionable SSE error (`AGENT_PROMPT_TOO_LARGE`) telling the user to reduce the selected skills / design-system context or pick an adapter with stdin support. The helper is unit-tested for both the oversized and short-prompt branches against the deepseek adapter so the guard can't silently regress.
- Prompt delivery: positional argv (no stdin sentinel; clap declares `prompt: String` as a required field). This means very large composed prompts can hit Windows' ~32 KB `CreateProcess` limit; for typical chat prompts this is non-issue. Upstream support for a `-` stdin sentinel would let us flip this to `promptViaStdin: true` like the other adapters. To avoid surfacing oversized prompts as a generic `spawn ENAMETOOLONG` / `E2BIG`, the adapter declares `maxPromptArgBytes` (currently 30,000) and `/api/chat` enforces it through two complementary guards: a fast pre-bin-resolution `checkPromptArgvBudget` against the raw composed prompt bytes, and a post-`buildArgs` `checkWindowsCmdShimCommandLineBudget` that — when the resolved binary is a Windows `.cmd` / `.bat` shim — recomputes the would-be `cmd.exe /d /s /c "<inner>"` command line using the same per-arg quote-doubling the platform layer applies on Windows. The second pass catches quote-heavy prompts (code blocks, JSON-shaped skill seeds) that fit under the raw byte budget but expand past CreateProcess's 32_767-char `lpCommandLine` cap. Both guards emit the same actionable `AGENT_PROMPT_TOO_LARGE` SSE error telling the user to reduce skills/design-system context, shorten the conversation, or pick an adapter with stdin support, and both are unit-tested (oversized + short-prompt branches, plus a quote-heavy regression for the Windows path) so the guard can't silently regress.
- Models: ships `deepseek-v4-pro` and `deepseek-v4-flash` as fallback hints (1M-token context windows, native thinking-mode streaming). Users can paste any other id (e.g. `nvidia-nim/deepseek-v4-pro`, `fireworks/deepseek-v4-flash`) via the Settings dialog's custom-model input.
- **Gotcha — auth state is not auto-detected.** DeepSeek TUI reads its API key from `~/.deepseek/config.toml` or `DEEPSEEK_API_KEY`. If the user hasn't run `deepseek auth set --provider deepseek` (or set the env var), the first run errors out with a non-actionable message. Detection currently only reports `available: true` based on the binary being on PATH; surface auth state via `deepseek doctor --json` in a follow-up.