Skip to content
Closed
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
32 changes: 1 addition & 31 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,39 +220,9 @@ jobs:
path: release-publish/*
if-no-files-found: error

publish_cli:
name: Publish CLI to npm
needs: [preflight, build]
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.ref }}

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: package.json
registry-url: https://registry.npmjs.org

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build CLI package
run: bun run build --filter=@t3tools/web --filter=t3

- name: Publish CLI package
run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose

release:
name: Publish GitHub Release
needs: [preflight, build, publish_cli]
needs: [preflight, build]
runs-on: ubuntu-24.04
steps:
- name: Download all desktop artifacts
Expand Down
51 changes: 48 additions & 3 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
import { normalizeModelSlug } from "@t3tools/shared/model";
import { Effect, ServiceMap } from "effect";

import { resolveWorkspaceCommandLaunch, toWslPath } from "./wsl";

type PendingRequestKey = string;

interface PendingRequest {
Expand Down Expand Up @@ -138,6 +140,13 @@ export interface CodexThreadSnapshot {
turns: CodexThreadTurnSnapshot[];
}

interface CodexSpawnConfig {
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly cwd: string;
readonly distribution: string | null;
}

const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
const CODEX_STDERR_LOG_REGEX =
Expand Down Expand Up @@ -168,6 +177,32 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

function resolveCodexSpawnConfig(input: {
readonly cwd: string;
readonly binaryPath: string;
}): CodexSpawnConfig {
const wslLaunch = resolveWorkspaceCommandLaunch({
workspaceRoot: input.cwd,
command: input.binaryPath,
args: ["app-server"],
});
if (wslLaunch) {
return {
command: wslLaunch.command,
args: wslLaunch.args,
cwd: wslLaunch.cwd,
distribution: wslLaunch.workspace.distribution,
};
}

return {
command: input.binaryPath,
args: ["app-server"],
cwd: input.cwd,
distribution: null,
};
}

export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot {
const record = asObject(response);
const account = asObject(record?.account) ?? record;
Expand Down Expand Up @@ -535,14 +570,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
const codexOptions = readCodexProviderOptions(input);
const codexBinaryPath = codexOptions.binaryPath ?? "codex";
const codexHomePath = codexOptions.homePath;
const child = spawn(codexBinaryPath, ["app-server"], {
const spawnConfig = resolveCodexSpawnConfig({
cwd: resolvedCwd,
binaryPath: codexBinaryPath,
});
const translatedCodexHomePath =
spawnConfig.distribution && codexHomePath
? toWslPath(codexHomePath, {
distribution: spawnConfig.distribution,
}) ?? codexHomePath
: codexHomePath;
const child = spawn(spawnConfig.command, [...spawnConfig.args], {
cwd: spawnConfig.cwd,
env: {
...process.env,
...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
...(translatedCodexHomePath ? { CODEX_HOME: translatedCodexHomePath } : {}),
},
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
shell: false,
});
const output = readline.createInterface({ input: child.stdout });

Expand Down
29 changes: 25 additions & 4 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 { resolveWorkspaceCommandLaunch, toWslPath } from "../../wsl.ts";
import { TextGenerationError } from "../Errors.ts";
import {
type BranchNameGenerationInput,
Expand Down Expand Up @@ -204,9 +205,29 @@ const makeCodexTextGeneration = Effect.gen(function* () {
const outputPath = yield* writeTempFile(operation, "codex-output", "");

const runCodexCommand = Effect.gen(function* () {
const wslLaunch = resolveWorkspaceCommandLaunch({
workspaceRoot: cwd,
command: "codex",
args: [
"exec",
"--ephemeral",
"-s",
"read-only",
"--model",
CODEX_MODEL,
"--config",
`model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
"--output-schema",
toWslPath(schemaPath) ?? schemaPath,
"--output-last-message",
toWslPath(outputPath) ?? outputPath,
...imagePaths.flatMap((imagePath) => ["--image", toWslPath(imagePath) ?? imagePath]),
"-",
],
});
const command = ChildProcess.make(
"codex",
[
wslLaunch?.command ?? "codex",
wslLaunch?.args ?? [
"exec",
"--ephemeral",
"-s",
Expand All @@ -223,8 +244,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
"-",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Critical Layers/CodexTextGeneration.ts:244

The fallback Windows execution path sets shell: false unconditionally, but shell: true is required on Windows to spawn npm-installed CLI tools (shims like codex.cmd). When wslLaunch is null (non-WSL path), spawn("codex", { shell: false }) fails with ENOENT because the .cmd extension cannot be resolved without a shell.

Also found in 2 other location(s)

apps/server/src/codexAppServerManager.ts:590

The spawn call now unconditionally uses shell: false, replacing the previous logic shell: process.platform === &#34;win32&#34;. This causes a regression for standard Windows usage (non-WSL) where the codex binary is a script (e.g., codex.cmd or codex.bat installed via npm) rather than a direct .exe. Without shell: true, spawn on Windows will fail to execute these scripts when relying on PATH resolution. The code should conditionally set shell to true for the native Windows fallback path, while keeping it false for the WSL execution path.

apps/server/src/provider/Layers/ProviderHealth.ts:184

The runCommand function hardcodes shell: false, removing the Windows-specific logic (shell: process.platform === &#34;win32&#34;) that was present in the previous runCodexCommand implementation. On Windows, executing CLI tools installed via npm or other package managers typically requires a shell (or explicitly appending .cmd) because they are wrapper scripts/batch files, not direct executables. This regression will likely cause the native codex health check to fail with ENOENT on Windows, incorrectly forcing a fallback to WSL or failing entirely if WSL is unavailable.

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

The fallback Windows execution path sets `shell: false` unconditionally, but `shell: true` is required on Windows to spawn npm-installed CLI tools (shims like `codex.cmd`). When `wslLaunch` is null (non-WSL path), `spawn("codex", { shell: false })` fails with `ENOENT` because the `.cmd` extension cannot be resolved without a shell.

Evidence trail:
1. apps/server/src/git/Layers/CodexTextGeneration.ts lines 228-249: shows `ChildProcess.make(wslLaunch?.command ?? "codex", ..., { shell: false })` - when wslLaunch is null, uses "codex" with shell:false
2. apps/server/src/wsl.ts lines 164-169: `resolveWorkspaceCommandLaunch` returns null when `parseWslWorkspacePath` returns null
3. apps/server/src/wsl.test.ts line 31: `parseWslWorkspacePath("C:\\repo", "win32")` returns null - confirms regular Windows paths don't return WSL config
4. https://nodejs.org/api/child_process.html - "On Windows, however, `.bat` and `.cmd` files are not executable on their own without a terminal, and therefore cannot be launched using `child_process.execFile()`. When running on Windows, `.bat` and `.cmd` files can be invoked using `child_process.spawn()` with the `shell` option set"
5. apps/server/src/codexAppServerManager.ts line 591: same pattern with native spawn() and shell: false

Also found in 2 other location(s):
- apps/server/src/codexAppServerManager.ts:590 -- The `spawn` call now unconditionally uses `shell: false`, replacing the previous logic `shell: process.platform === "win32"`. This causes a regression for standard Windows usage (non-WSL) where the `codex` binary is a script (e.g., `codex.cmd` or `codex.bat` installed via npm) rather than a direct `.exe`. Without `shell: true`, `spawn` on Windows will fail to execute these scripts when relying on PATH resolution. The code should conditionally set `shell` to true for the native Windows fallback path, while keeping it false for the WSL execution path.
- apps/server/src/provider/Layers/ProviderHealth.ts:184 -- The `runCommand` function hardcodes `shell: false`, removing the Windows-specific logic (`shell: process.platform === "win32"`) that was present in the previous `runCodexCommand` implementation. On Windows, executing CLI tools installed via npm or other package managers typically requires a shell (or explicitly appending `.cmd`) because they are wrapper scripts/batch files, not direct executables. This regression will likely cause the native `codex` health check to fail with `ENOENT` on Windows, incorrectly forcing a fallback to WSL or failing entirely if WSL is unavailable.

],
{
cwd,
shell: process.platform === "win32",
cwd: wslLaunch?.cwd ?? cwd,
shell: false,
stdin: {
stream: Stream.make(new TextEncoder().encode(prompt)),
},
Expand Down
68 changes: 60 additions & 8 deletions apps/server/src/provider/Layers/ProviderHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { Effect, Layer, Sink, Stream } from "effect";
import * as PlatformError from "effect/PlatformError";
import { ChildProcessSpawner } from "effect/unstable/process";

import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth";
import {
checkCodexProviderStatus,
makeCheckCodexProviderStatus,
parseAuthStatusFromOutput,
} from "./ProviderHealth";

// ── Test helpers ────────────────────────────────────────────────────

Expand All @@ -26,13 +30,31 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) {
}

function mockSpawnerLayer(
handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number },
handler: (input: {
command: string;
args: ReadonlyArray<string>;
}) => { stdout: string; stderr: string; code: number },
) {
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> };
return Effect.try({
try: () =>
mockHandle(
handler({
command: cmd.command,
args: cmd.args,
}),
),
catch: (cause) =>
PlatformError.systemError({
_tag: "BadResource",
module: "ChildProcess",
method: "spawn",
description: cause instanceof Error ? cause.message : String(cause),
}),
});
}),
);
}
Expand Down Expand Up @@ -64,7 +86,7 @@ it.effect("returns ready when codex is installed and authenticated", () =>
assert.strictEqual(status.authStatus, "authenticated");
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
mockSpawnerLayer(({ args }) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
Expand Down Expand Up @@ -98,7 +120,7 @@ it.effect("returns unauthenticated when auth probe reports login required", () =
);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
mockSpawnerLayer(({ args }) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
if (joined === "login status") {
Expand All @@ -125,7 +147,7 @@ it.effect(
);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
mockSpawnerLayer(({ args }) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
if (joined === "login status")
Expand All @@ -149,7 +171,7 @@ it.effect("returns warning when login status command is unsupported", () =>
);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
mockSpawnerLayer(({ args }) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
if (joined === "login status") {
Expand All @@ -161,6 +183,36 @@ it.effect("returns warning when login status command is unsupported", () =>
),
);

it.effect("falls back to WSL when codex is only available there on Windows", () =>
Effect.gen(function* () {
const status = yield* makeCheckCodexProviderStatus("win32");
assert.strictEqual(status.provider, "codex");
assert.strictEqual(status.status, "ready");
assert.strictEqual(status.available, true);
assert.strictEqual(status.authStatus, "authenticated");
}).pipe(
Effect.provide(
mockSpawnerLayer(({ args }) => {
const expectedVersionProbe =
'--exec /bin/sh -lc shell="${SHELL:-/bin/sh}"; exec "$shell" -l -i -c \'exec "$@"\' wsl-shell "$@" wsl-shell codex --version';
const expectedAuthProbe =
'--exec /bin/sh -lc shell="${SHELL:-/bin/sh}"; exec "$shell" -l -i -c \'exec "$@"\' wsl-shell "$@" wsl-shell codex login status';
const joined = args.join(" ");
if (joined === "--version") {
throw new Error("spawn codex ENOENT");
}
if (joined === expectedVersionProbe) {
return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
}
if (joined === expectedAuthProbe) {
return { stdout: "Logged in\n", stderr: "", code: 0 };
}
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

// ── Pure function tests ─────────────────────────────────────────────

it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => {
Expand Down
Loading