mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user