diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index a2f376cf9f..755dca997e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).", "_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.", "_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.", "_rebaseline_2026_07_21_7301_universal_cooldown_retry": "PR #7301 (ViFigueiredo, feat/universal-cooldown-retry) own growth, surfaced during rebase-onto-tip reconciliation (fast-gates PR->release do not run check:file-size): open-sse/services/combo.ts 3388->3479 (+91) generalizes the existing quota-share-only cooldown-aware retry (dispatchWithCooldownRetry) to ALL combo strategies (priority/weighted/round-robin/etc), gates it on the model lockout's REAL reason (not a hardcoded \"rate_limit\") via the existing getModelLockoutInfo/resolveComboCooldownWaitDecision chokepoint, and adds a global comboTimeoutMs guard + aggregated per-target error diagnostics on exhaustion. Companion leaves open-sse/services/combo/comboCooldownRetry.ts (+29), combo/autoStrategy.ts (+9, auto-strategy combo-ref guard so a combo cannot recursively reference itself as a candidate), combo/comboSetup.ts (+3), comboConfig.ts (+6) all stay under cap. Irreducible orchestration wiring at the existing dispatch chokepoint (mirrors the quota-share-only precedent this PR generalizes); not extractable without hiding the retry loop. Covered by tests/unit/combo-auto-candidate-expansion.test.ts (+61, combo-ref guard), tests/unit/combo-routing-engine.test.ts (+68, universal retry across strategies + comboTimeoutMs, no-explicit-any clean), tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts (+136, quota_exhausted vs rate_limit reason gating, disabled-flag passthrough). Structural shrink of combo.ts tracked in #3501.", @@ -259,7 +260,7 @@ "src/app/api/providers/[id]/test/route.ts": 940, "src/app/api/usage/analytics/route.ts": 948, "src/app/api/v1/models/catalog.ts": 1615, - "src/lib/cloudflaredTunnel.ts": 934, + "src/lib/cloudflaredTunnel.ts": 935, "src/lib/db/apiKeys.ts": 1662, "src/lib/db/core.ts": 1825, "src/lib/db/migrationRunner.ts": 1125, diff --git a/src/lib/cloudflaredTunnel.ts b/src/lib/cloudflaredTunnel.ts index 2ca1af1414..90eb9e4377 100644 --- a/src/lib/cloudflaredTunnel.ts +++ b/src/lib/cloudflaredTunnel.ts @@ -815,6 +815,7 @@ export async function startCloudflaredTunnel(): Promise }); const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), { + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildCloudflaredChildEnv(), }); diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts index 9cfa291e0d..e49142bffe 100644 --- a/src/lib/plugins/loader.ts +++ b/src/lib/plugins/loader.ts @@ -137,6 +137,7 @@ export async function loadPlugin( }; const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], { + windowsHide: true, env, stdio: ["ignore", "ignore", "ignore", "ipc"], }); diff --git a/src/lib/providerModels/cursorAgent.ts b/src/lib/providerModels/cursorAgent.ts index 49749dcb22..e15d4ce546 100644 --- a/src/lib/providerModels/cursorAgent.ts +++ b/src/lib/providerModels/cursorAgent.ts @@ -13,7 +13,7 @@ function runCursorAgent( return new Promise((resolve, reject) => { let child; try { - child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] }); + child = spawn(binary, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); } catch (err) { reject(err); return; diff --git a/src/lib/services/ServiceSupervisor.ts b/src/lib/services/ServiceSupervisor.ts index 0125bd2056..3c4ee4dd51 100644 --- a/src/lib/services/ServiceSupervisor.ts +++ b/src/lib/services/ServiceSupervisor.ts @@ -12,6 +12,26 @@ import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } const CRASH_FAST_THRESHOLD_MS = 5_000; +/** + * Builds the `spawn()` options for a supervised service child process. + * `windowsHide: true` suppresses the transient conhost.exe/cmd console + * window Windows briefly flashes open for spawned child processes (#8131). + * Exported (rather than inlined) so a unit test can assert on it directly + * instead of mocking `node:child_process`. + */ +export function buildServiceSpawnOptions( + env: NodeJS.ProcessEnv | undefined, + cwd: string | undefined +): { env: NodeJS.ProcessEnv | undefined; cwd: string | undefined; detached: boolean; stdio: ["ignore", "pipe", "pipe"]; windowsHide: boolean } { + return { + env, + cwd, + detached: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }; +} + export class ServiceSupervisor extends EventEmitter { private state: ServiceState = "stopped"; private health: HealthState = "unknown"; @@ -90,12 +110,7 @@ export class ServiceSupervisor extends EventEmitter { const { command, args, env, cwd } = this.config.spawnArgs(); - const child = spawn(command, args, { - env, - cwd, - detached: false, - stdio: ["ignore", "pipe", "pipe"], - }); + const child = spawn(command, args, buildServiceSpawnOptions(env, cwd)); this.childProcess = child; this.pid = child.pid ?? null; diff --git a/src/lib/services/installers/utils.ts b/src/lib/services/installers/utils.ts index 2d4e85afd5..aff9c8c622 100644 --- a/src/lib/services/installers/utils.ts +++ b/src/lib/services/installers/utils.ts @@ -92,6 +92,7 @@ export interface NpmExecOptions { env: NodeJS.ProcessEnv; maxBuffer: number; shell?: boolean; + windowsHide: boolean; } /** @@ -123,6 +124,9 @@ export function buildNpmExecOptions( timeout: options.timeoutMs, env, maxBuffer: 10 * 1024 * 1024, // 10 MB for npm output + // Suppress the transient conhost.exe/cmd console window Windows briefly + // flashes open for spawned child processes (see #8131). + windowsHide: true, }; if (platform === "win32") { execOptions.shell = true; diff --git a/src/lib/versionManager/processManager.ts b/src/lib/versionManager/processManager.ts index 4926a9deb2..ec538ab770 100644 --- a/src/lib/versionManager/processManager.ts +++ b/src/lib/versionManager/processManager.ts @@ -8,6 +8,27 @@ import { setToolStatus, getVersionManagerTool } from "@/lib/db/versionManager"; const DEFAULT_PORT = 8317; const GRACEFUL_TIMEOUT_MS = 5000; +/** + * Builds the `spawn()` options for the cliproxyapi child process. + * `windowsHide: true` suppresses the transient conhost.exe/cmd console + * window Windows briefly flashes open for spawned child processes (#8131). + * Exported (rather than inlined) so a unit test can assert on it directly + * instead of mocking `node:child_process`. + */ +export function buildCliproxyapiSpawnOptions(): { + detached: boolean; + stdio: ["ignore", "pipe", "pipe"]; + env: NodeJS.ProcessEnv; + windowsHide: boolean; +} { + return { + detached: false, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + windowsHide: true, + }; +} + function defaultConfigDir(): string { return process.env.CLIPROXYAPI_CONFIG_DIR || path.join(os.homedir(), ".cli-proxy-api"); } @@ -42,11 +63,11 @@ export async function startProcess( const actualConfigDir = configDir || defaultConfigDir(); await writeConfig(actualConfigDir, actualPort); - const child = spawn(binaryPath, ["-c", path.join(actualConfigDir, "config.yaml")], { - detached: false, - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); + const child = spawn( + binaryPath, + ["-c", path.join(actualConfigDir, "config.yaml")], + buildCliproxyapiSpawnOptions() + ); child.stdout?.on("data", () => {}); child.stderr?.on("data", () => {}); diff --git a/src/mitm/inspector/systemProxyConfig.ts b/src/mitm/inspector/systemProxyConfig.ts index dbe4c76fad..57d7494ba2 100644 --- a/src/mitm/inspector/systemProxyConfig.ts +++ b/src/mitm/inspector/systemProxyConfig.ts @@ -41,10 +41,7 @@ export interface WindowsPreviousState { netshOutput: string; } -export type PreviousState = - | MacOsPreviousState - | LinuxPreviousState - | WindowsPreviousState; +export type PreviousState = MacOsPreviousState | LinuxPreviousState | WindowsPreviousState; export interface ApplyResult { platform: Platform; @@ -67,7 +64,7 @@ function defaultExec( options: ExecFileOptions = {} ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { - execFile(file, args, options, (err, stdout, stderr) => { + execFile(file, args, { windowsHide: true, ...options }, (err, stdout, stderr) => { if (err) { reject(err); return; @@ -152,12 +149,7 @@ async function macosApply(port: number): Promise { async function macosRevert(state: MacOsPreviousState): Promise { const service = state.service; if (state.http.enabled && state.http.host && state.http.port) { - await execImpl("networksetup", [ - "-setwebproxy", - service, - state.http.host, - state.http.port, - ]); + await execImpl("networksetup", ["-setwebproxy", service, state.http.host, state.http.port]); } else { await execImpl("networksetup", ["-setwebproxystate", service, "off"]); } @@ -186,10 +178,7 @@ async function readGsetting(key: string): Promise { } } -async function readGsubsetting( - scheme: string, - key: string -): Promise { +async function readGsubsetting(scheme: string, key: string): Promise { try { // HR#13: concat (not template) — scheme is a hardcoded "http"|"https" constant. const { stdout } = await execImpl("gsettings", [ @@ -232,20 +221,10 @@ async function linuxRevert(state: LinuxPreviousState): Promise { await execImpl("gsettings", ["set", "org.gnome.system.proxy.http", "port", state.httpPort]); } if (state.httpsHost) { - await execImpl("gsettings", [ - "set", - "org.gnome.system.proxy.https", - "host", - state.httpsHost, - ]); + await execImpl("gsettings", ["set", "org.gnome.system.proxy.https", "host", state.httpsHost]); } if (state.httpsPort) { - await execImpl("gsettings", [ - "set", - "org.gnome.system.proxy.https", - "port", - state.httpsPort, - ]); + await execImpl("gsettings", ["set", "org.gnome.system.proxy.https", "port", state.httpsPort]); } } @@ -308,8 +287,7 @@ export async function revert(previousState: PreviousState | unknown): Promise { try { const result = execFileSync("npm", ["config", "get", "prefix"], { + windowsHide: true, timeout: 5000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], @@ -874,7 +876,10 @@ const locateCommandCandidate = async ( // This avoids searching PATH and reduces attack surface let bestKnownPathFailure: KnownPathResult | null = null; if (toolId) { - const { match, bestFailure } = await findKnownPathMatch(getKnownToolPaths(toolId), checkKnownPath); + const { match, bestFailure } = await findKnownPathMatch( + getKnownToolPaths(toolId), + checkKnownPath + ); if (match) { return { command: commands[0], diff --git a/tests/unit/services/installers/runNpm-shell-5379.test.ts b/tests/unit/services/installers/runNpm-shell-5379.test.ts index 0c8a9e64ad..4c5cc6d263 100644 --- a/tests/unit/services/installers/runNpm-shell-5379.test.ts +++ b/tests/unit/services/installers/runNpm-shell-5379.test.ts @@ -52,6 +52,16 @@ test("buildNpmExecOptions: carries cwd, timeout and maxBuffer through", () => { assert.equal(opts.maxBuffer, 10 * 1024 * 1024); }); +// Regression for #8131 — windowsHide: true must be set on every execFile/spawn +// options object so Windows does not flash a transient conhost.exe/cmd window +// for the npm child process runNpm() spawns. +test("buildNpmExecOptions: windowsHide is always true regardless of platform", () => { + for (const platform of ["win32", "linux", "darwin", "freebsd"] as NodeJS.Platform[]) { + const opts = buildNpmExecOptions(platform, { timeoutMs: 1000 }); + assert.equal(opts.windowsHide, true, `${platform} must set windowsHide: true (#8131)`); + } +}); + test("SERVICE_VERSION_PATTERN: accepts dist-tags and semver", () => { for (const v of ["latest", "next", "1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "0.4.59"]) { assert.ok(SERVICE_VERSION_PATTERN.test(v), `${v} should be valid`); diff --git a/tests/unit/windows-hide-child-process-spawns-8131.test.ts b/tests/unit/windows-hide-child-process-spawns-8131.test.ts new file mode 100644 index 0000000000..4777ac6cc1 --- /dev/null +++ b/tests/unit/windows-hide-child-process-spawns-8131.test.ts @@ -0,0 +1,40 @@ +/** + * Regression test for #8131 — on Windows, child processes spawned without + * `windowsHide: true` cause a transient conhost.exe/cmd console window to + * flash open. PR #8167 audited most spawn/exec call sites but missed the + * two `child_process.spawn()` call sites below plus the `execFile()` wrapper + * used by `runNpm()` (covered separately in + * tests/unit/services/installers/runNpm-shell-5379.test.ts). + * + * Both `ServiceSupervisor.start()` and `processManager.startProcess()` use a + * bare named `import { spawn } from "node:child_process"`, which Node's ESM + * live-binding semantics make impossible to intercept with + * `mock.method()`/`mock.module()` without the (project-wide, not currently + * enabled) `--experimental-test-module-mocks` flag. Rather than widen the + * test-runner flags, the options object each call site passes to `spawn()` + * is factored into a small, pure, exported builder function — asserted on + * directly here instead of mocking `node:child_process`. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildServiceSpawnOptions } from "../../src/lib/services/ServiceSupervisor.ts"; +import { buildCliproxyapiSpawnOptions } from "../../src/lib/versionManager/processManager.ts"; + +test("ServiceSupervisor: buildServiceSpawnOptions sets windowsHide: true (#8131)", () => { + const opts = buildServiceSpawnOptions({ FOO: "bar" }, "/tmp/cwd"); + assert.equal(opts.windowsHide, true); + // Sanity: the rest of the previously-inline options object still round-trips. + assert.equal(opts.env?.FOO, "bar"); + assert.equal(opts.cwd, "/tmp/cwd"); + assert.equal(opts.detached, false); + assert.deepEqual(opts.stdio, ["ignore", "pipe", "pipe"]); +}); + +test("processManager: buildCliproxyapiSpawnOptions sets windowsHide: true (#8131)", () => { + const opts = buildCliproxyapiSpawnOptions(); + assert.equal(opts.windowsHide, true); + assert.equal(opts.detached, false); + assert.deepEqual(opts.stdio, ["ignore", "pipe", "pipe"]); + assert.ok(opts.env, "env should be populated from process.env"); +});