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..d281de889e 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,10 +37,23 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } +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 { + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); +} + 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 d00be86d3e..5ee235a2e6 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -8,7 +8,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, @@ -21,6 +21,7 @@ import { detailFromResult, extractAuthBoolean, isCommandMissingCause, + makeProviderCommand, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, @@ -463,9 +464,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 421621c969..c80ddc1754 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -20,7 +20,7 @@ import { Result, Stream, } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, @@ -28,6 +28,7 @@ import { detailFromResult, extractAuthBoolean, isCommandMissingCause, + makeProviderCommand, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, @@ -321,8 +322,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 9c12048e45..16a7ccff4e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -38,7 +38,30 @@ 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 } { + if (args.length > 0) { + return { command, args }; + } + + const parts = command.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 +69,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 +87,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 +98,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))); }), ); } @@ -429,6 +458,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(); @@ -1052,6 +1119,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.test.ts b/apps/server/src/provider/providerSnapshot.test.ts new file mode 100644 index 0000000000..d51cf30436 --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -0,0 +1,67 @@ +import { Effect, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + collectStreamAsString, + makeProviderCommand, + quoteWindowsShellArgument, +} 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"); + }); + + 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", () => { + 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\\\\"', + ); + }); +}); + +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 40246563ae..3ba377d31a 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -39,6 +39,65 @@ export function isCommandMissingCause(error: Error): boolean { return lower.includes("enoent") || lower.includes("notfound"); } +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 `"${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(shellCommand, [], { + shell: true, + env: options?.env, + }); +} + +function decodeProcessOutput(output: Uint8Array): string { + if (process.platform !== "win32") { + return Buffer.from(output).toString("utf8"); + } + + const buffer = Buffer.from(output); + 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; + } + + const minimumZeroBytes = Math.max(1, Math.floor(buffer.length / 4)); + if (zeroBytes >= minimumZeroBytes) { + return buffer.toString("utf16le"); + } + } + + return buffer.toString("utf8"); +} + export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -155,9 +214,16 @@ export const collectStreamAsString = ( stream: Stream.Stream, ): Effect.Effect => stream.pipe( - Stream.decodeText(), 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)), ), ); 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: {