forked from nexu-io/open-design
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprompt-budget.ts
More file actions
250 lines (240 loc) Β· 12.2 KB
/
Copy pathprompt-budget.ts
File metadata and controls
250 lines (240 loc) Β· 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import type { RuntimeAgentDef, RuntimePromptBudgetError } from './types.js';
function promptArgvBudgetMessage(
def: RuntimeAgentDef,
bytes: number,
limit: number,
): string {
if (def.id === 'deepseek') {
return (
`${def.name} currently accepts prompts only as a command-line argument, and this run's composed prompt exceeds the safe size (${bytes} > ${limit} bytes). ` +
'Reduce the selected skills/design-system context or conversation length, or use DeepSeek through an API/provider model connection for large contexts. Pick a stdin-capable adapter when the prompt must include large local context.'
);
}
return (
`${def.name} requires the prompt as a command-line argument and this run's composed prompt exceeds the safe size (${bytes} > ${limit} bytes). ` +
'Reduce the selected skills/design-system context, shorten the conversation, or pick an adapter with stdin support.'
);
}
// `maxPromptArgBytes` is sized for Windows' ~32 KB CreateProcess
// command-line limit (see deepseek.ts). On POSIX the per-arg ceiling is far
// higher β Linux's MAX_ARG_STRLEN is 128 KB and macOS's ARG_MAX is 256 KB
// (total argv+env) β and the *real* Windows command-line cap is guarded
// precisely post-buildArgs by checkWindowsCmdShim/DirectExeCommandLineBudget.
// So applying the conservative Windows byte budget unconditionally on
// macOS/Linux false-positives on normal projects (system prompt + DESIGN.md +
// skills β 50-70 KB) with a confusing "prompt too long" (issue #4473). On
// POSIX we keep a much larger guard β still below Linux's 128 KiB per-arg
// ceiling β purely so a runaway prompt still fails fast with the actionable
// message instead of a generic spawn E2BIG.
const POSIX_ARGV_PROMPT_BUDGET = 120_000;
function resolveArgvPromptBudget(
maxPromptArgBytes: number,
platform: NodeJS.Platform,
): number {
if (platform === 'win32') return maxPromptArgBytes;
return Math.max(maxPromptArgBytes, POSIX_ARGV_PROMPT_BUDGET);
}
export function checkPromptArgvBudget(
def: RuntimeAgentDef | null | undefined,
composed: unknown,
platform: NodeJS.Platform = process.platform,
): RuntimePromptBudgetError | null {
if (!def || typeof def.maxPromptArgBytes !== 'number') return null;
const bytes = Buffer.byteLength(
typeof composed === 'string' ? composed : '',
'utf8',
);
const limit = resolveArgvPromptBudget(def.maxPromptArgBytes, platform);
if (bytes <= limit) return null;
return {
code: 'AGENT_PROMPT_TOO_LARGE',
message: promptArgvBudgetMessage(def, bytes, limit),
bytes,
limit,
};
}
// 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. The `%` β `"^%"` substitution
// neutralizes cmd.exe's percent-expansion for prompts that ride argv
// (DeepSeek TUI today): `%name%` pairs would otherwise be expanded from
// the daemon environment before the child reads them, leaking secrets
// like `%DEEPSEEK_API_KEY%` whenever the prompt mentions an env-var name.
function quoteForWindowsCmdShim(value: unknown): string {
const str = String(value ?? '');
if (!/[\s"&<>|^%]/.test(str)) return str;
const escaped = str.replace(/"/g, '""').replace(/%/g, '"^%"');
return `"${escaped}"`;
}
// Mirror of libuv's `quote_cmd_arg` (process-stdio.c), the exact rule
// Node uses on Windows when it composes a CreateProcess command line for
// a direct executable spawn (not a `.cmd` / `.bat` shim, which goes
// through `quoteForWindowsCmdShim` above). Each embedded `"` becomes
// `\"`, every backslash that ends up adjacent to a quote (or to the
// closing wrap quote) gets doubled, and an arg with whitespace or a
// quote is wrapped in outer `"..."`. Kept local so the budget check
// works on macOS/Linux test hosts against a fake `C:\β¦\foo.exe` path.
function quoteForWindowsDirectExe(value: unknown): string {
const str = String(value ?? '');
// libuv emits a literal `""` for an empty argv entry so it survives
// CommandLineToArgvW round-tripping; mirror that.
if (str.length === 0) return '""';
// Fast path: no whitespace and no quote β pass through unchanged. This
// matches libuv's `wcspbrk(source, L" \t\"")` early return.
if (!/[\s"]/.test(str)) return str;
// No quote, no backslash: simple wrap, no per-char escaping needed.
if (!/[\\"]/.test(str)) return `"${str}"`;
// Slow path: walk the string, counting consecutive backslashes so we
// can double them whenever they precede a `"` or the closing wrap
// quote. Following the documented Windows convention:
// - 2n backslashes + `"` β emit `\\` Γ 2n + `\"`
// - 2n+1 backslashes + `"` β emit `\\` Γ (2n+1) + `\"`
// - n backslashes not before `"` β emit `\\` Γ n unchanged
// - trailing backslashes (before the closing wrap quote) β doubled
let result = '"';
let backslashes = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '\\') {
backslashes++;
} else if (ch === '"') {
result += '\\'.repeat(2 * backslashes + 1) + '"';
backslashes = 0;
} else {
result += '\\'.repeat(backslashes) + ch;
backslashes = 0;
}
}
result += '\\'.repeat(2 * backslashes) + '"';
return result;
}
// 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: RuntimeAgentDef | null | undefined,
resolvedBin: unknown,
args: unknown,
): RuntimePromptBudgetError | null {
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,
};
}
// Heuristic: does `resolvedBin` look like a Windows path? Used by the
// direct-exe guard so a test on a POSIX host can drive a fake
// `C:\β¦\foo.exe` path through the same math the daemon would run on
// Windows, while still skipping POSIX-shaped paths (which never go
// through CreateProcess).
function looksLikeWindowsPath(p: unknown): boolean {
if (typeof p !== 'string' || p.length === 0) return false;
// Drive-letter (`C:\β¦`, `C:/β¦`) or UNC (`\\server\share\β¦`).
return /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\');
}
// Companion to `checkWindowsCmdShimCommandLineBudget` for argv-bound
// adapters whose binary resolves directly to a Windows executable
// (a cargo-installed `deepseek.exe`, a hand-built release, or any other
// non-shim install path). `createCommandInvocation` does *not* wrap the
// call in `cmd.exe /d /s /c "<inner>"` for those β but Node/libuv still
// composes a CreateProcess `lpCommandLine` by walking each argv entry
// through `quote_cmd_arg`, which doubles backslashes adjacent to quotes
// and escapes every embedded `"` as `\"`. A quote-heavy prompt that fits
// under the raw `maxPromptArgBytes` budget can therefore still expand
// past the kernel's 32_767-char `lpCommandLine` cap on a direct `.exe`
// spawn, surfacing as a generic `spawn ENAMETOOLONG` instead of the
// adapter-named `AGENT_PROMPT_TOO_LARGE` the budget guard exists to
// emit. Returns the same error shape as the cmd-shim guard 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 is a `.cmd` / `.bat` shim β that's handled by
// `checkWindowsCmdShimCommandLineBudget` so we don't double-emit;
// - the resolved binary is not a Windows path (no CreateProcess
// command-line shape to budget);
// - the assembled command line fits under the safe limit.
//
// Pure: takes `resolvedBin` and `args` explicitly so a test on macOS can
// pass a fake `C:\β¦\deepseek.exe` and exercise the same math the daemon
// would run on Windows. The libuv quoting math lives in
// `quoteForWindowsDirectExe` above.
export function checkWindowsDirectExeCommandLineBudget(
def: RuntimeAgentDef | null | undefined,
resolvedBin: unknown,
args: unknown,
): RuntimePromptBudgetError | null {
if (!def || typeof def.maxPromptArgBytes !== 'number') return null;
if (typeof resolvedBin !== 'string' || resolvedBin.length === 0) return null;
// The cmd-shim guard owns `.bat` / `.cmd`; skip those here so a single
// oversized prompt doesn't trip both guards.
if (/\.(bat|cmd)$/i.test(resolvedBin)) return null;
// Only fire for Windows-shaped resolved binaries. On POSIX-shaped
// paths, `execvp` accepts each argv entry as a separate buffer β
// there's no command-line concatenation step that could expand past a
// kernel cap, so we have nothing to guard.
if (!looksLikeWindowsPath(resolvedBin)) return null;
const argList = Array.isArray(args) ? args : [];
// `[command, ...args].map(quote).join(' ')` is the exact shape libuv
// builds before handing it to CreateProcess.
const commandLineLength = [resolvedBin, ...argList]
.map(quoteForWindowsDirectExe)
.join(' ').length;
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 builds a CreateProcess command line and this run's prompt would expand past the limit ` +
`after libuv quote-escaping (${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,
};
}