From 3203a023f4bddf46aa03c9a01fd5d55dbfe7ff12 Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:12:14 -0300 Subject: [PATCH 1/7] fix(server): handle localized Windows provider probe output Decode Windows provider probe output correctly and recognize localized command-not-found errors so Codex and Claude status checks stop surfacing garbled messages in settings. --- apps/server/src/processRunner.test.ts | 19 ++- apps/server/src/processRunner.ts | 20 ++- .../src/provider/Layers/ClaudeProvider.ts | 7 +- .../src/provider/Layers/CodexProvider.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 128 +++++++++++++++++- apps/server/src/provider/providerSnapshot.ts | 59 +++++++- 6 files changed, 221 insertions(+), 18 deletions(-) diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index dd909116d4..7caa888f4f 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { runProcess } from "./processRunner"; +import { isWindowsCommandNotFound, runProcess } from "./processRunner"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("runProcess", () => { it("fails when output exceeds max buffer in default mode", async () => { @@ -20,4 +24,15 @@ describe("runProcess", () => { expect(result.stdoutTruncated).toBe(true); expect(result.stderrTruncated).toBe(false); }); + + it("recognizes localized Windows command-not-found errors", () => { + vi.stubGlobal("process", { ...process, platform: "win32" }); + + expect( + isWindowsCommandNotFound( + 1, + "'codex' n\u00e3o \u00e9 reconhecido como um comando interno\r\nou externo, um programa oper\u00e1vel ou um arquivo em lotes.\r\n", + ), + ).toBe(true); + }); }); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 5402612887..aaa34dfe70 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,10 +37,28 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } +function lowerCaseOutput(value: string): string { + return value.toLowerCase(); +} + +const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ + /is not recognized as an internal or external command/i, + /n.o . reconhecido como um comando interno/i, + /non .* riconosciuto come comando interno o esterno/i, + /n. est pas reconnu en tant que commande interne/i, + /no se reconoce como un comando interno o externo/i, + /wird nicht als interner oder externer befehl erkannt/i, +] as const; + +function hasWindowsCommandNotFoundMessage(output: string): boolean { + const normalized = lowerCaseOutput(output); + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(normalized)); +} + export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { if (process.platform !== "win32") return false; if (code === 9009) return true; - return /is not recognized as an internal or external command/i.test(stderr); + return hasWindowsCommandNotFoundMessage(stderr); } function normalizeExitError( diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9feec28637..107690a593 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -7,7 +7,7 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; @@ -17,6 +17,7 @@ import { detailFromResult, extractAuthBoolean, isCommandMissingCause, + makeProviderCommand, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, @@ -435,9 +436,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly Effect.flatMap((service) => service.getSettings), Effect.map((settings) => settings.providers.claudeAgent), ); - const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { - shell: process.platform === "win32", - }); + const command = makeProviderCommand(claudeSettings.binaryPath, args); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3509fa9257..af5d463876 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -19,7 +19,7 @@ import { Result, Stream, } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, @@ -27,6 +27,7 @@ import { detailFromResult, extractAuthBoolean, isCommandMissingCause, + makeProviderCommand, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, @@ -319,8 +320,7 @@ const runCodexCommand = Effect.fn("runCodexCommand")(function* (args: ReadonlyAr const codexSettings = yield* settingsService.getSettings.pipe( Effect.map((settings) => settings.providers.codex), ); - const command = ChildProcess.make(codexSettings.binaryPath, [...args], { - shell: process.platform === "win32", + const command = makeProviderCommand(codexSettings.binaryPath, args, { env: { ...process.env, ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3d6f418603..3d2d011749 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -38,7 +38,40 @@ import { ProviderRegistry } from "../Services/ProviderRegistry"; const encoder = new TextEncoder(); -function mockHandle(result: { stdout: string; stderr: string; code: number }) { +function encodeUtf16Le(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, "utf16le")); +} + +function unwrapWindowsProviderCommand( + command: string, + args: ReadonlyArray, +): { command: string; args: ReadonlyArray } { + const comSpec = process.env.ComSpec ?? "cmd.exe"; + if (command !== comSpec || args.length !== 5) { + return { command, args }; + } + + const [u, d, s, c, shellCommand] = args; + if (u !== "/u" || d !== "/d" || s !== "/s" || c !== "/c") { + return { command, args }; + } + + if (!shellCommand) { + return { command, args }; + } + + const parts = shellCommand.split(" "); + return { + command: parts[0] ?? command, + args: parts.slice(1), + }; +} + +function mockHandle(result: { + stdout: string | Uint8Array; + stderr: string | Uint8Array; + code: number; +}) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), @@ -46,8 +79,12 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout)), - stderr: Stream.make(encoder.encode(result.stderr)), + stdout: Stream.make( + typeof result.stdout === "string" ? encoder.encode(result.stdout) : result.stdout, + ), + stderr: Stream.make( + typeof result.stderr === "string" ? encoder.encode(result.stderr) : result.stderr, + ), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -60,8 +97,9 @@ function mockSpawnerLayer( return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.args))); + const cmd = command as unknown as { command: string; args: ReadonlyArray }; + const normalized = unwrapWindowsProviderCommand(cmd.command, cmd.args); + return Effect.succeed(mockHandle(handler(normalized.args))); }), ); } @@ -70,13 +108,14 @@ function mockCommandSpawnerLayer( handler: ( command: string, args: ReadonlyArray, - ) => { stdout: string; stderr: string; code: number }, + ) => { stdout: string | Uint8Array; stderr: string | Uint8Array; code: number }, ) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const cmd = command as unknown as { command: string; args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); + const normalized = unwrapWindowsProviderCommand(cmd.command, cmd.args); + return Effect.succeed(mockHandle(handler(normalized.command, normalized.args))); }), ); } @@ -386,6 +425,44 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), ); + it.effect("treats localized Windows cmd missing-command output as unavailable", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + try { + const status = yield* checkCodexProviderStatus().pipe( + Effect.provide( + mockCommandSpawnerLayer((command, args) => { + if (command !== "codex" || args.join(" ") !== "--version") { + throw new Error(`Unexpected command: ${command} ${args.join(" ")}`); + } + return { + stdout: new Uint8Array(), + stderr: encodeUtf16Le( + "'codex' n\u00e3o \u00e9 reconhecido como um comando interno\r\nou externo, um programa oper\u00e1vel ou um arquivo em lotes.\r\n", + ), + code: 1, + }; + }), + ), + ); + + assert.strictEqual(status.installed, false); + assert.strictEqual( + status.message, + "Codex CLI (`codex`) is not installed or not on PATH.", + ); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }), + ); + it.effect("returns unavailable when codex is below the minimum supported version", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -926,6 +1003,43 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); + it.effect("treats localized Windows cmd output for missing claude as unavailable", () => + Effect.gen(function* () { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + try { + const status = yield* checkClaudeProviderStatus().pipe( + Effect.provide( + mockCommandSpawnerLayer((command, args) => { + if (command !== "claude" || args.join(" ") !== "--version") { + throw new Error(`Unexpected command: ${command} ${args.join(" ")}`); + } + return { + stdout: new Uint8Array(), + stderr: encodeUtf16Le( + "'claude' n\u00e3o \u00e9 reconhecido como um comando interno\r\nou externo, um programa oper\u00e1vel ou um arquivo em lotes.\r\n", + ), + code: 1, + }; + }), + ), + ); + + assert.strictEqual(status.installed, false); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }), + ); + it.effect("returns error when version check fails with non-zero exit code", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4c80d78e20..2117c06c3e 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -38,6 +38,63 @@ export function isCommandMissingCause(error: unknown): boolean { return lower.includes("enoent") || lower.includes("notfound"); } +function quoteWindowsShellArgument(value: string): string { + if (value.length === 0) return '""'; + + const escaped = value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1"); + + return /[\s"&<>|^()%!]/.test(value) ? `"${escaped}"` : escaped; +} + +export function makeProviderCommand( + binaryPath: string, + args: ReadonlyArray, + options?: { + readonly env?: NodeJS.ProcessEnv | undefined; + }, +): ChildProcess.Command { + if (process.platform !== "win32") { + return ChildProcess.make(binaryPath, [...args], { + shell: false, + env: options?.env, + }); + } + + const shellCommand = [ + quoteWindowsShellArgument(binaryPath), + ...args.map(quoteWindowsShellArgument), + ].join(" "); + + return ChildProcess.make( + process.env.ComSpec ?? "cmd.exe", + ["/u", "/d", "/s", "/c", shellCommand], + { + shell: false, + env: options?.env, + }, + ); +} + +function decodeProcessChunk(chunk: Uint8Array): string { + if (process.platform !== "win32") { + return Buffer.from(chunk).toString("utf8"); + } + + const buffer = Buffer.from(chunk); + if (buffer.length >= 2 && buffer.length % 2 === 0) { + let zeroBytes = 0; + for (let index = 1; index < buffer.length; index += 2) { + if (buffer[index] === 0) zeroBytes += 1; + } + + if (zeroBytes >= Math.floor(buffer.length / 4)) { + return buffer.toString("utf16le"); + } + } + + return buffer.toString("utf8"); +} + export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -150,7 +207,7 @@ export const collectStreamAsString = ( stream: Stream.Stream, ): Effect.Effect => stream.pipe( - Stream.decodeText(), + Stream.map(decodeProcessChunk), Stream.runFold( () => "", (acc, chunk) => acc + chunk, From 21be66ebea2f0c288f7c4e3fb34f9ca5b528708d Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:23:17 -0300 Subject: [PATCH 2/7] test(web): fix catalog lint warning Extract the unresolved registry read helper in the catalog test so the linter no longer flags the fallback function as unnecessarily re-created inside the test case. --- apps/web/src/environments/runtime/catalog.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index f078129463..f01b6cd32c 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -13,6 +13,10 @@ import { waitForSavedEnvironmentRegistryHydration, } from "./catalog"; +function unresolvedRegistryRead(): void { + throw new Error("Registry read resolver was not initialized."); +} + describe("environment runtime catalog stores", () => { beforeEach(async () => { vi.stubGlobal("window", { @@ -95,9 +99,7 @@ describe("environment runtime catalog stores", () => { }); it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - let resolveRegistryRead: () => void = () => { - throw new Error("Registry read resolver was not initialized."); - }; + let resolveRegistryRead: () => void = unresolvedRegistryRead; vi.stubGlobal("window", { nativeApi: { From 6f246866af182e1f97a3624e98e0bf6147d33544 Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:27:13 -0300 Subject: [PATCH 3/7] fix(server): decode UTF-16LE provider streams safely Accumulate provider probe output before decoding so Windows UTF-16LE streams stay intact even when chunk boundaries split code units across odd-sized buffers. --- .../src/provider/providerSnapshot.test.ts | 24 +++++++++++++++++++ apps/server/src/provider/providerSnapshot.ts | 21 ++++++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/provider/providerSnapshot.test.ts diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts new file mode 100644 index 0000000000..a09d367f3b --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -0,0 +1,24 @@ +import { Effect, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { collectStreamAsString } from "./providerSnapshot"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("collectStreamAsString", () => { + it("decodes UTF-16LE Windows output across odd chunk boundaries", async () => { + vi.stubGlobal("process", { ...process, platform: "win32" }); + + const output = Buffer.from("n\u00e3o \u00e9 reconhecido", "utf16le"); + const firstChunk = new Uint8Array(output.subarray(0, 5)); + const secondChunk = new Uint8Array(output.subarray(5)); + + const result = await Effect.runPromise( + collectStreamAsString(Stream.make(firstChunk, secondChunk)), + ); + + expect(result).toBe("n\u00e3o \u00e9 reconhecido"); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 2117c06c3e..3925617c16 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -75,13 +75,13 @@ export function makeProviderCommand( ); } -function decodeProcessChunk(chunk: Uint8Array): string { +function decodeProcessOutput(output: Uint8Array): string { if (process.platform !== "win32") { - return Buffer.from(chunk).toString("utf8"); + return Buffer.from(output).toString("utf8"); } - const buffer = Buffer.from(chunk); - if (buffer.length >= 2 && buffer.length % 2 === 0) { + const buffer = Buffer.from(output); + if (buffer.length >= 2) { let zeroBytes = 0; for (let index = 1; index < buffer.length; index += 2) { if (buffer[index] === 0) zeroBytes += 1; @@ -207,9 +207,16 @@ export const collectStreamAsString = ( stream: Stream.Stream, ): Effect.Effect => stream.pipe( - Stream.map(decodeProcessChunk), Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, + () => ({ chunks: [] as Buffer[], totalLength: 0 }), + (state, chunk) => { + const buffer = Buffer.from(chunk); + state.chunks.push(buffer); + state.totalLength += buffer.length; + return state; + }, + ), + Effect.map(({ chunks, totalLength }) => + decodeProcessOutput(Buffer.concat(chunks, totalLength)), ), ); From fb0465e58481895be4094e6720acfec96202da2d Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:29:11 -0300 Subject: [PATCH 4/7] fix(server): preserve unquoted Windows path backslashes Only apply trailing backslash escaping when a provider command argument actually needs quoting so plain Windows paths keep their original value. --- apps/server/src/provider/providerSnapshot.test.ts | 14 +++++++++++++- apps/server/src/provider/providerSnapshot.ts | 9 +++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index a09d367f3b..982e399938 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,7 +1,7 @@ import { Effect, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { collectStreamAsString } from "./providerSnapshot"; +import { collectStreamAsString, quoteWindowsShellArgument } from "./providerSnapshot"; afterEach(() => { vi.unstubAllGlobals(); @@ -22,3 +22,15 @@ describe("collectStreamAsString", () => { expect(result).toBe("n\u00e3o \u00e9 reconhecido"); }); }); + +describe("quoteWindowsShellArgument", () => { + it("preserves trailing backslashes when quoting is not needed", () => { + expect(quoteWindowsShellArgument("C:\\tools\\")).toBe("C:\\tools\\"); + }); + + it("doubles trailing backslashes only when quoting is needed", () => { + expect(quoteWindowsShellArgument("C:\\Program Files\\tool\\")).toBe( + '"C:\\Program Files\\tool\\\\"', + ); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 3925617c16..b632c9ffff 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -38,12 +38,17 @@ export function isCommandMissingCause(error: unknown): boolean { return lower.includes("enoent") || lower.includes("notfound"); } -function quoteWindowsShellArgument(value: string): string { +export function quoteWindowsShellArgument(value: string): string { if (value.length === 0) return '""'; + const requiresQuoting = /[\s"&<>|^()%!]/.test(value); + if (!requiresQuoting) { + return value; + } + const escaped = value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1"); - return /[\s"&<>|^()%!]/.test(value) ? `"${escaped}"` : escaped; + return `"${escaped}"`; } export function makeProviderCommand( From 19a503ae34d011ad278ad474d3a7c68bdc0c074b Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:34:06 -0300 Subject: [PATCH 5/7] fix(server): avoid short UTF-16LE false positives Require an even-length buffer and at least one matching zero byte before decoding Windows provider output as UTF-16LE so short UTF-8 responses remain intact. --- apps/server/src/provider/providerSnapshot.test.ts | 10 ++++++++++ apps/server/src/provider/providerSnapshot.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 982e399938..6caae72de6 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -21,6 +21,16 @@ describe("collectStreamAsString", () => { expect(result).toBe("n\u00e3o \u00e9 reconhecido"); }); + + it("does not misclassify short UTF-8 Windows output as UTF-16LE", async () => { + vi.stubGlobal("process", { ...process, platform: "win32" }); + + const result = await Effect.runPromise( + collectStreamAsString(Stream.make(new Uint8Array(Buffer.from("AB", "utf8")))), + ); + + expect(result).toBe("AB"); + }); }); describe("quoteWindowsShellArgument", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index b632c9ffff..5076ba3f72 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -86,13 +86,14 @@ function decodeProcessOutput(output: Uint8Array): string { } const buffer = Buffer.from(output); - if (buffer.length >= 2) { + if (buffer.length >= 2 && buffer.length % 2 === 0) { let zeroBytes = 0; for (let index = 1; index < buffer.length; index += 2) { if (buffer[index] === 0) zeroBytes += 1; } - if (zeroBytes >= Math.floor(buffer.length / 4)) { + const minimumZeroBytes = Math.max(1, Math.floor(buffer.length / 4)); + if (zeroBytes >= minimumZeroBytes) { return buffer.toString("utf16le"); } } From c66a714203120e7f1223011169caa5a9953ec0e8 Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:52:05 -0300 Subject: [PATCH 6/7] refactor(server): remove redundant command output lowercase Use the existing case-insensitive Windows command-not-found regexes directly instead of lowercasing output first. --- apps/server/src/processRunner.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index aaa34dfe70..d281de889e 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,10 +37,6 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } -function lowerCaseOutput(value: string): string { - return value.toLowerCase(); -} - const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ /is not recognized as an internal or external command/i, /n.o . reconhecido como um comando interno/i, @@ -51,8 +47,7 @@ const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ ] as const; function hasWindowsCommandNotFoundMessage(output: string): boolean { - const normalized = lowerCaseOutput(output); - return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(normalized)); + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); } export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { From 27a8248fd7332e996c81c658b5086791430d2f72 Mon Sep 17 00:00:00 2001 From: raulpesilva Date: Fri, 10 Apr 2026 17:55:22 -0300 Subject: [PATCH 7/7] fix(server): support Windows provider paths with spaces Run Windows provider probes through a shell command string so custom binary paths containing spaces are quoted the way cmd.exe expects. --- .../provider/Layers/ProviderRegistry.test.ts | 14 ++--------- .../src/provider/providerSnapshot.test.ts | 23 ++++++++++++++++++- apps/server/src/provider/providerSnapshot.ts | 12 ++++------ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3d2d011749..0dc7cb4423 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -46,21 +46,11 @@ function unwrapWindowsProviderCommand( command: string, args: ReadonlyArray, ): { command: string; args: ReadonlyArray } { - const comSpec = process.env.ComSpec ?? "cmd.exe"; - if (command !== comSpec || args.length !== 5) { + if (args.length > 0) { return { command, args }; } - const [u, d, s, c, shellCommand] = args; - if (u !== "/u" || d !== "/d" || s !== "/s" || c !== "/c") { - return { command, args }; - } - - if (!shellCommand) { - return { command, args }; - } - - const parts = shellCommand.split(" "); + const parts = command.split(" "); return { command: parts[0] ?? command, args: parts.slice(1), diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 6caae72de6..d51cf30436 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,7 +1,11 @@ import { Effect, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { collectStreamAsString, quoteWindowsShellArgument } from "./providerSnapshot"; +import { + collectStreamAsString, + makeProviderCommand, + quoteWindowsShellArgument, +} from "./providerSnapshot"; afterEach(() => { vi.unstubAllGlobals(); @@ -44,3 +48,20 @@ describe("quoteWindowsShellArgument", () => { ); }); }); + +describe("makeProviderCommand", () => { + it("uses a shell command string for Windows paths with spaces", () => { + vi.stubGlobal("process", { ...process, platform: "win32" }); + + const command = makeProviderCommand("C:\\Program Files\\Codex\\codex.exe", ["--version"]); + const resolved = command as unknown as { + command: string; + args: ReadonlyArray; + options: { shell?: boolean }; + }; + + expect(resolved.command).toBe('"C:\\Program Files\\Codex\\codex.exe" --version'); + expect(resolved.args).toEqual([]); + expect(resolved.options.shell).toBe(true); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 5076ba3f72..6a8640583d 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -70,14 +70,10 @@ export function makeProviderCommand( ...args.map(quoteWindowsShellArgument), ].join(" "); - return ChildProcess.make( - process.env.ComSpec ?? "cmd.exe", - ["/u", "/d", "/s", "/c", shellCommand], - { - shell: false, - env: options?.env, - }, - ); + return ChildProcess.make(shellCommand, [], { + shell: true, + env: options?.env, + }); } function decodeProcessOutput(output: Uint8Array): string {