fix(core): parse QWEN_SERVE_MCP_CLIENT_BUDGET strictly as a decimal integer (#5752)

* fix(core): parse QWEN_SERVE_MCP_CLIENT_BUDGET strictly as a decimal integer

readBudgetFromEnv used Number(rawBudget), so Number("0x10")=16, Number("1e2")=100
and Number("1.0")=1 all passed isInteger and were silently accepted as a budget.
Require plain decimal digits (/^\d+$/) like the other integer env vars, keeping
the existing stderr breadcrumb for invalid values.

* fix(acp): apply strict budget-env parsing to createWorkspaceMcpBudget

The pooled path parsed QWEN_SERVE_MCP_CLIENT_BUDGET with a loose Number() +
isInteger, so 0x10, 1e2 and 1.0 silently set a budget while the manager's
readBudgetFromEnv (already strict) rejected them. Use the same /^\d+$/ +
isSafeInteger parse and emit the same stderr breadcrumb on invalid input.
Export the function and cover the parsing with unit tests.

* docs: sync readBudgetFromEnv JSDoc with the stderr breadcrumb

The doc still said invalid budget values are silently ignored, but the
function now writes a stderr breadcrumb when it rejects one. Match the
doc to the behavior.
This commit is contained in:
Yufeng He 2026-06-24 13:51:44 +08:00 committed by GitHub
parent d360dd276e
commit ebf29f1187
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 86 additions and 21 deletions

View file

@ -562,6 +562,7 @@ import {
normalizeCoreSettingValue,
extractFilesFromTarGz,
fetchAllowedGitHub,
createWorkspaceMcpBudget,
} from './acpAgent.js';
import { gzipSync } from 'node:zlib';
import type { Config } from '@qwen-code/qwen-code-core';
@ -7291,3 +7292,40 @@ describe('sessionLanguage multi-session propagation', () => {
await agentPromise;
});
});
describe('createWorkspaceMcpBudget — env parsing', () => {
const KEY = 'QWEN_SERVE_MCP_CLIENT_BUDGET';
const MODE = 'QWEN_SERVE_MCP_BUDGET_MODE';
const onEvent = vi.fn();
afterEach(() => {
delete process.env[KEY];
delete process.env[MODE];
vi.clearAllMocks();
});
it('accepts a plain positive decimal integer', () => {
process.env[KEY] = '100';
expect(createWorkspaceMcpBudget(onEvent)).toBeDefined();
});
it('accepts a trimmed decimal integer', () => {
process.env[KEY] = ' 42 ';
expect(createWorkspaceMcpBudget(onEvent)).toBeDefined();
});
// Mirrors McpClientManager.readBudgetFromEnv: a loose Number() would coerce
// these (0x10=16, 1e2=100, 1.0=1) and silently set a budget. The strict
// /^\d+$/ + isSafeInteger parse must reject them.
it.each(['0x10', '1e2', '1.0', '0b101', '5 abc', 'abc', '-5', '0', ' '])(
'rejects non-decimal-integer value %j',
(raw) => {
process.env[KEY] = raw;
expect(createWorkspaceMcpBudget(onEvent)).toBeUndefined();
},
);
it('returns undefined when the budget env var is unset', () => {
expect(createWorkspaceMcpBudget(onEvent)).toBeUndefined();
});
});

View file

@ -2395,34 +2395,37 @@ function parsePoolDrainMs(envValue: string | undefined): number {
* invokes `tryReserve`/`release`; this helper produces the controller
* and wires the event callback.
*/
function createWorkspaceMcpBudget(
export function createWorkspaceMcpBudget(
onEvent: (event: McpBudgetEvent) => void,
): WorkspaceMcpBudget | undefined {
const rawBudget = process.env['QWEN_SERVE_MCP_CLIENT_BUDGET'];
const rawMode = process.env['QWEN_SERVE_MCP_BUDGET_MODE'];
// Match `McpClientManager.readBudgetFromEnv`'s parsing exactly.
// Use `Number(...)` + `Number.isInteger` so the pool and the manager
// Match `McpClientManager.readBudgetFromEnv`'s parsing exactly: only plain
// decimal digits set a budget. A loose `Number(...)` would silently accept
// `0x10`=16, `1e2`=100, and `1.0`=1 (all pass `isInteger`); the strict
// `/^\d+$/` + `isSafeInteger` check rejects them so the pool and the manager
// honor the same env values.
const budget =
rawBudget !== undefined && rawBudget !== '' ? Number(rawBudget) : undefined;
let budget: number | undefined;
if (rawBudget !== undefined && rawBudget !== '') {
const trimmed = rawBudget.trim();
const parsed = Number(trimmed);
if (/^\d+$/.test(trimmed) && Number.isSafeInteger(parsed) && parsed > 0) {
budget = parsed;
} else {
process.stderr.write(
`qwen serve: ignoring invalid QWEN_SERVE_MCP_CLIENT_BUDGET=` +
`'${rawBudget}' (expected positive integer); ` +
`MCP budget enforcement disabled for this child.\n`,
);
}
}
const mode: McpBudgetMode = (() => {
if (rawMode === 'enforce' || rawMode === 'warn' || rawMode === 'off') {
return rawMode;
}
return budget !== undefined &&
Number.isFinite(budget) &&
Number.isInteger(budget) &&
budget > 0
? 'warn'
: 'off';
return budget !== undefined ? 'warn' : 'off';
})();
if (
mode === 'off' ||
budget === undefined ||
!Number.isFinite(budget) ||
!Number.isInteger(budget) ||
budget <= 0
) {
if (mode === 'off' || budget === undefined) {
return undefined;
}
return new WorkspaceMcpBudget({

View file

@ -2627,6 +2627,25 @@ describe('McpClientManager — PR 14 guardrails', () => {
}
});
it('readBudgetFromEnv rejects non-decimal budget values (hex / scientific / float)', async () => {
const writeSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
try {
for (const bad of ['0x10', '1e2', '1.0']) {
process.env['QWEN_SERVE_MCP_CLIENT_BUDGET'] = bad;
const manager = mkManager({ config: configWithServers({}) });
// Pre-fix Number('0x10')=16 / Number('1e2')=100 slipped through as a budget.
expect(manager.getMcpClientBudget()).toBeUndefined();
}
// a plain decimal integer is still accepted.
process.env['QWEN_SERVE_MCP_CLIENT_BUDGET'] = '16';
const ok = mkManager({ config: configWithServers({}) });
expect(ok.getMcpClientBudget()).toBe(16);
} finally {
writeSpy.mockRestore();
delete process.env['QWEN_SERVE_MCP_CLIENT_BUDGET'];
}
});
it('readResource rejects existing-but-now-disabled servers (wenshao R7 #5 line 1342)', async () => {
// Pre-fix: a server connected pre-disable and then operator-
// disabled mid-session via settings reload would still serve

View file

@ -269,7 +269,8 @@ export function mcpTransportOf(config: MCPServerConfig): McpTransportKind {
* behavior, no enforcement.
*
* `QWEN_SERVE_MCP_CLIENT_BUDGET` positive integer; non-numeric /
* zero / negative / NaN are silently ignored (treated as unset).
* zero / negative / NaN are rejected (treated as unset) and a
* stderr breadcrumb is written so the misconfiguration is visible.
* `QWEN_SERVE_MCP_BUDGET_MODE` `enforce|warn|off`. Defaults to
* `warn` when a budget is set, `off` otherwise.
*/
@ -278,8 +279,12 @@ function readBudgetFromEnv(): McpBudgetConfig {
const rawMode = process.env['QWEN_SERVE_MCP_BUDGET_MODE'];
let clientBudget: number | undefined;
if (rawBudget !== undefined && rawBudget !== '') {
const parsed = Number(rawBudget);
if (Number.isFinite(parsed) && Number.isInteger(parsed) && parsed > 0) {
// Parse strictly as a decimal integer: Number('0x10')=16, Number('1e2')=100
// and Number('1.0')=1 all pass isInteger, so a loose parse would silently
// accept them. Only plain decimal digits should set a budget.
const trimmed = rawBudget.trim();
const parsed = Number(trimmed);
if (/^\d+$/.test(trimmed) && Number.isSafeInteger(parsed) && parsed > 0) {
clientBudget = parsed;
} else {
// operator typos