Skip to content
19 changes: 17 additions & 2 deletions apps/server/src/processRunner.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
});
});
15 changes: 14 additions & 1 deletion apps/server/src/processRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,6 +17,7 @@ import {
detailFromResult,
extractAuthBoolean,
isCommandMissingCause,
makeProviderCommand,
parseGenericCliVersion,
providerModelsFromSettings,
spawnAndCollect,
Expand Down Expand Up @@ -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);
});

Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import {
Result,
Stream,
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { ChildProcessSpawner } from "effect/unstable/process";

import {
buildServerProvider,
DEFAULT_TIMEOUT_MS,
detailFromResult,
extractAuthBoolean,
isCommandMissingCause,
makeProviderCommand,
parseGenericCliVersion,
providerModelsFromSettings,
spawnAndCollect,
Expand Down Expand Up @@ -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 } : {}),
Expand Down
118 changes: 111 additions & 7 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,43 @@ 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<string>,
): { command: string; args: ReadonlyArray<string> } {
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)),
isRunning: Effect.succeed(false),
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,
Expand All @@ -60,8 +87,9 @@ function mockSpawnerLayer(
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const cmd = command as unknown as { args: ReadonlyArray<string> };
return Effect.succeed(mockHandle(handler(cmd.args)));
const cmd = command as unknown as { command: string; args: ReadonlyArray<string> };
const normalized = unwrapWindowsProviderCommand(cmd.command, cmd.args);
return Effect.succeed(mockHandle(handler(normalized.args)));
}),
);
}
Expand All @@ -70,13 +98,14 @@ function mockCommandSpawnerLayer(
handler: (
command: string,
args: ReadonlyArray<string>,
) => { 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<string> };
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)));
}),
);
}
Expand Down Expand Up @@ -386,6 +415,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();
Expand Down Expand Up @@ -926,6 +993,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();
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/provider/providerSnapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
options: { shell?: boolean };
};

expect(resolved.command).toBe('"C:\\Program Files\\Codex\\codex.exe" --version');
expect(resolved.args).toEqual([]);
expect(resolved.options.shell).toBe(true);
});
});
Loading
Loading