Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DEVELOPMENT_ICON_OVERRIDES, PUBLISH_ICON_OVERRIDES } from "../../../scr
import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
import rootPackageJson from "../../../package.json" with { type: "json" };
import serverPackageJson from "../package.json" with { type: "json" };
import { resolveShellCommand } from "../src/windowsShell.ts";

class CliError extends Data.TaggedError("CliError")<{
readonly message: string;
Expand Down Expand Up @@ -125,16 +126,17 @@ const buildCmd = Command.make(
const fs = yield* FileSystem.FileSystem;
const repoRoot = yield* RepoRoot;
const serverDir = path.join(repoRoot, "apps/server");
const tsdownCommand = resolveShellCommand("bun", ["tsdown"], { cwd: serverDir });

yield* Effect.log("[cli] Running tsdown...");
yield* runCommand(
ChildProcess.make({
cwd: serverDir,
ChildProcess.make(tsdownCommand.command, [...tsdownCommand.args], {
cwd: tsdownCommand.cwd,
...(tsdownCommand.env ? { env: { ...process.env, ...tsdownCommand.env } } : {}),
stdout: config.verbose ? "inherit" : "ignore",
stderr: "inherit",
// Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
shell: process.platform === "win32",
})`bun tsdown`,
shell: tsdownCommand.shell,
}),
);

const webDist = path.join(repoRoot, "apps/web/dist");
Expand Down Expand Up @@ -220,15 +222,16 @@ const publishCmd = Command.make(
const args = ["publish", "--access", config.access, "--tag", config.tag];
if (config.provenance) args.push("--provenance");
if (config.dryRun) args.push("--dry-run");
const publishCommand = resolveShellCommand("npm", args, { cwd: serverDir });

yield* Effect.log(`[cli] Running: npm ${args.join(" ")}`);
yield* runCommand(
ChildProcess.make("npm", [...args], {
cwd: serverDir,
ChildProcess.make(publishCommand.command, [...publishCommand.args], {
cwd: publishCommand.cwd,
...(publishCommand.env ? { env: { ...process.env, ...publishCommand.env } } : {}),
stdout: config.verbose ? "inherit" : "ignore",
stderr: "inherit",
// Windows needs shell mode to resolve .cmd shims.
shell: process.platform === "win32",
shell: publishCommand.shell,
}),
);
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ describe("sendTurn", () => {
],
model: "gpt-5.3-codex",
collaborationMode: {
mode: "default",
mode: "code",
settings: {
model: "gpt-5.3-codex",
reasoning_effort: "medium",
Expand Down
21 changes: 15 additions & 6 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "./provider/codexCliVersion";
import { resolveShellCommand } from "./windowsShell";

type PendingRequestKey = string;

Expand Down Expand Up @@ -420,7 +421,7 @@ function buildCodexCollaborationMode(input: {
readonly effort?: string;
}):
| {
mode: "default" | "plan";
mode: "code" | "plan";
settings: {
model: string;
reasoning_effort: string;
Expand All @@ -433,7 +434,7 @@ function buildCodexCollaborationMode(input: {
}
const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex";
return {
mode: input.interactionMode,
mode: input.interactionMode === "plan" ? "plan" : "code",
settings: {
model,
reasoning_effort: input.effort ?? "medium",
Expand Down Expand Up @@ -548,14 +549,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
cwd: resolvedCwd,
...(codexHomePath ? { homePath: codexHomePath } : {}),
});
const child = spawn(codexBinaryPath, ["app-server"], {
const launchCommand = resolveShellCommand(codexBinaryPath, ["app-server"], {
cwd: resolvedCwd,
});
const child = spawn(launchCommand.command, launchCommand.args, {
cwd: launchCommand.cwd,
env: {
...process.env,
...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
...(launchCommand.env ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
shell: launchCommand.shell,
});
const output = readline.createInterface({ input: child.stdout });

Expand Down Expand Up @@ -1526,14 +1531,18 @@ function assertSupportedCodexCliVersion(input: {
readonly cwd: string;
readonly homePath?: string;
}): void {
const result = spawnSync(input.binaryPath, ["--version"], {
const versionCommand = resolveShellCommand(input.binaryPath, ["--version"], {
cwd: input.cwd,
});
const result = spawnSync(versionCommand.command, versionCommand.args, {
cwd: versionCommand.cwd,
env: {
...process.env,
...(input.homePath ? { CODEX_HOME: input.homePath } : {}),
...(versionCommand.env ?? {}),
},
encoding: "utf8",
shell: process.platform === "win32",
shell: versionCommand.shell,
stdio: ["ignore", "pipe", "pipe"],
timeout: CODEX_VERSION_CHECK_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { resolveShellCommand } from "../../windowsShell.ts";
import { TextGenerationError } from "../Errors.ts";
import {
type BranchNameGenerationInput,
Expand Down Expand Up @@ -204,7 +205,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
const outputPath = yield* writeTempFile(operation, "codex-output", "");

const runCodexCommand = Effect.gen(function* () {
const command = ChildProcess.make(
const resolvedCommand = resolveShellCommand(
"codex",
[
"exec",
Expand All @@ -222,9 +223,17 @@ const makeCodexTextGeneration = Effect.gen(function* () {
...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
"-",
],
{ cwd },
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/CodexTextGeneration.ts:227

When cwd is a Windows UNC path, resolveShellCommand returns an env object containing only the UNC wrapper variables. Passing this directly to ChildProcess.make replaces the entire process environment, stripping PATH and SystemRoot, which causes the codex command to fail with "Command not found" because executables cannot be located.

Also found in 1 other location(s)

apps/server/scripts/cli.ts:228

In publishCmd, resolveShellCommand is used to construct the npm publish command. The returned object includes an env property that is critical for correct execution on Windows UNC paths. This env property is ignored when calling ChildProcess.make, which causes the publish operation to fail when executed from a UNC path on Windows.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/git/Layers/CodexTextGeneration.ts around line 227:

When `cwd` is a Windows UNC path, `resolveShellCommand` returns an `env` object containing only the UNC wrapper variables. Passing this directly to `ChildProcess.make` replaces the entire process environment, stripping `PATH` and `SystemRoot`, which causes the `codex` command to fail with "Command not found" because executables cannot be located.

Evidence trail:
- apps/server/src/windowsShell.ts:29-54 - `buildWindowsUncCommand` returns env with ONLY `__T3CODE_WINDOWS_UNC_*` vars
- apps/server/src/windowsShell.ts:66-75 - `resolveShellCommand` returns this limited env when UNC path detected
- apps/server/src/git/Layers/CodexTextGeneration.ts:228-239 - passes `resolvedCommand.env` directly without merging with `process.env`
- apps/server/src/processRunner.ts:143-145 - shows correct pattern: `{ ...(options.env ?? process.env), ...resolvedCommand.env }`
- apps/server/src/codexAppServerManager.ts:556-561 - shows correct pattern: `{ ...process.env, ...(launchCommand.env ?? {}) }`

Also found in 1 other location(s):
- apps/server/scripts/cli.ts:228 -- In `publishCmd`, `resolveShellCommand` is used to construct the `npm publish` command. The returned object includes an `env` property that is critical for correct execution on Windows UNC paths. This `env` property is ignored when calling `ChildProcess.make`, which causes the publish operation to fail when executed from a UNC path on Windows.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0952c5f. UNC wrapper env vars are now merged with process.env in CodexTextGeneration, and the same merge was added to the CLI build/publish subprocess paths so PATH/SystemRoot are preserved for UNC working directories.

const command = ChildProcess.make(
resolvedCommand.command,
[...resolvedCommand.args],
{
cwd,
shell: process.platform === "win32",
cwd: resolvedCommand.cwd,
...(resolvedCommand.env
? { env: { ...process.env, ...resolvedCommand.env } }
: {}),
shell: resolvedCommand.shell,
stdin: {
stream: Stream.make(new TextEncoder().encode(prompt)),
},
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { extname, join } from "node:path";

import { EDITORS, type EditorId } from "@t3tools/contracts";
import { ServiceMap, Schema, Effect, Layer } from "effect";
import { resolveShellCommand } from "./windowsShell";

// ==============================
// Definitions
Expand Down Expand Up @@ -232,10 +233,12 @@ export const launchDetached = (launch: EditorLaunch) =>
yield* Effect.callback<void, OpenError>((resume) => {
let child;
try {
child = spawn(launch.command, [...launch.args], {
const resolvedCommand = resolveShellCommand(launch.command, launch.args);
child = spawn(resolvedCommand.command, [...resolvedCommand.args], {
cwd: resolvedCommand.cwd,
detached: true,
stdio: "ignore",
shell: process.platform === "win32",
shell: resolvedCommand.shell,
});
} catch (error) {
return resume(
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/processRunner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process";

import { resolveShellCommand } from "./windowsShell";

export interface ProcessRunOptions {
cwd?: string | undefined;
timeoutMs?: number | undefined;
Expand Down Expand Up @@ -135,11 +137,14 @@ export async function runProcess(
const outputMode = options.outputMode ?? "error";

return new Promise<ProcessRunResult>((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
const resolvedCommand = resolveShellCommand(command, args, { cwd: options.cwd });
const child = spawn(resolvedCommand.command, resolvedCommand.args, {
cwd: resolvedCommand.cwd,
env: resolvedCommand.env
? { ...(options.env ?? process.env), ...resolvedCommand.env }
: options.env,
stdio: "pipe",
shell: process.platform === "win32",
shell: resolvedCommand.shell,
});

let stdout = "";
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/provider/Layers/ProviderHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "../codexCliVersion";
import { resolveShellCommand } from "../../windowsShell.ts";
import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";

const DEFAULT_TIMEOUT_MS = 4_000;
Expand Down Expand Up @@ -179,8 +180,11 @@ const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.
const runCodexCommand = (args: ReadonlyArray<string>) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const command = ChildProcess.make("codex", [...args], {
shell: process.platform === "win32",
const resolvedCommand = resolveShellCommand("codex", args);
const command = ChildProcess.make(resolvedCommand.command, [...resolvedCommand.args], {
cwd: resolvedCommand.cwd,
...(resolvedCommand.env ? { env: resolvedCommand.env } : {}),
shell: resolvedCommand.shell,
});

const child = yield* spawner.spawn(command);
Expand Down
83 changes: 83 additions & 0 deletions apps/server/src/windowsShell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";

import { isWindowsUncPath, resolveShellCommand } from "./windowsShell";

describe("isWindowsUncPath", () => {
it("detects UNC paths on Windows", () => {
expect(isWindowsUncPath("\\\\wsl.localhost\\Ubuntu\\home\\user\\repo", "win32")).toBe(true);
});

it("ignores drive-letter paths on Windows", () => {
expect(isWindowsUncPath("C:\\Users\\user\\repo", "win32")).toBe(false);
});

it("ignores UNC-looking paths on non-Windows platforms", () => {
expect(isWindowsUncPath("\\\\wsl.localhost\\Ubuntu\\home\\user\\repo", "linux")).toBe(false);
});

it("ignores Windows verbatim paths on Windows", () => {
expect(isWindowsUncPath("\\\\?\\C:\\Users\\user\\repo", "win32")).toBe(false);
});
});

describe("resolveShellCommand", () => {
it("keeps the normal Windows shell path for drive-letter cwd values", () => {
expect(
resolveShellCommand("codex", ["app-server"], {
cwd: "C:\\Users\\user\\repo",
platform: "win32",
}),
).toEqual({
command: "codex",
args: ["app-server"],
cwd: "C:\\Users\\user\\repo",
shell: true,
});
});

it("wraps UNC cwd values through cmd pushd on Windows", () => {
expect(
resolveShellCommand("codex", ["app-server"], {
cwd: "\\\\wsl.localhost\\Ubuntu\\home\\user\\repo",
platform: "win32",
}),
).toEqual({
command: "cmd.exe",
args: [
"/d",
"/c",
'pushd %__T3CODE_WINDOWS_UNC_CWD% && %__T3CODE_WINDOWS_UNC_COMMAND% %__T3CODE_WINDOWS_UNC_ARG_0%',
],
cwd: undefined,
env: {
__T3CODE_WINDOWS_UNC_COMMAND: '"codex"',
__T3CODE_WINDOWS_UNC_CWD: '"\\\\wsl.localhost\\Ubuntu\\home\\user\\repo"',
__T3CODE_WINDOWS_UNC_ARG_0: '"app-server"',
},
shell: false,
});
});

it("quotes command paths with spaces for UNC cwd values", () => {
expect(
resolveShellCommand("C:\\Users\\user\\AppData\\Roaming\\npm\\codex.cmd", ["--version"], {
cwd: "\\\\wsl.localhost\\Ubuntu\\home\\user\\repo",
platform: "win32",
}),
).toEqual({
command: "cmd.exe",
args: [
"/d",
"/c",
"pushd %__T3CODE_WINDOWS_UNC_CWD% && call %__T3CODE_WINDOWS_UNC_COMMAND% %__T3CODE_WINDOWS_UNC_ARG_0%",
],
cwd: undefined,
env: {
__T3CODE_WINDOWS_UNC_COMMAND: '"C:\\Users\\user\\AppData\\Roaming\\npm\\codex.cmd"',
__T3CODE_WINDOWS_UNC_CWD: '"\\\\wsl.localhost\\Ubuntu\\home\\user\\repo"',
__T3CODE_WINDOWS_UNC_ARG_0: '"--version"',
},
shell: false,
});
});
});
Loading