Skip to content

Commit 8a1c198

Browse files
authored
fix(windows): Make it compatible with codex.cmd (#326)
1 parent f55e4ba commit 8a1c198

16 files changed

Lines changed: 308 additions & 33 deletions

docs/cli.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,8 +377,7 @@ Notes:
377377

378378
- `--runtime codex` requires a local `codex` CLI that supports `app-server --listen stdio://`.
379379
- Binary lookup uses `PATH` by default and can be overridden with `CSGCLAW_CODEX_PATH`.
380-
On Windows, use a native `codex.exe` rather than the PowerShell `codex.ps1` shim.
381-
Legacy `codex.cmd` and `codex.bat` values fall back to a sibling or CSGClaw-managed native executable.
380+
On Windows, native `codex.exe` and npm's `codex.cmd`/`codex.bat` shims are supported; the PowerShell `codex.ps1` shim is not.
382381

383382
### `csgclaw user`
384383

docs/cli.zh.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ csgclaw agent delete --all --force
376376
说明:
377377

378378
- `--runtime codex` 依赖本地已安装且支持 `app-server --listen stdio://``codex` CLI。
379-
- 默认从 `PATH` 查找二进制,也可以用 `CSGCLAW_CODEX_PATH` 显式覆盖。Windows 下请使用 `codex.cmd` `codex.exe`不要使用 PowerShell 的 `codex.ps1` shim。
379+
- 默认从 `PATH` 查找二进制,也可以用 `CSGCLAW_CODEX_PATH` 显式覆盖。Windows 支持 `codex.cmd``codex.bat` `codex.exe`但不支持 PowerShell 的 `codex.ps1` shim。
380380

381381
### `csgclaw user`
382382

docs/config.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,7 @@ For complete Codex worker profiles, CSGClaw writes `~/.csgclaw/agents/<agent-nam
128128
When a worker uses the Codex runtime, CSGClaw launches the local `codex` CLI with `codex app-server --listen stdio://`. You can override the binary lookup with:
129129

130130
- `CSGCLAW_CODEX_PATH` to point at a preinstalled `codex` binary.
131-
On Windows, point it at a native `codex.exe`.
132-
A legacy npm `codex.cmd` or `codex.bat` value makes CSGClaw look for a sibling native executable, then fall back to the CSGClaw-managed `codex.exe`; `codex.ps1` is not supported.
131+
On Windows, native `codex.exe` and npm's `codex.cmd`/`codex.bat` shims are supported; `codex.ps1` is not. CSGClaw launches command shims through `cmd.exe` and prefers a sibling native executable when one exists.
133132
- `CSGCLAW_CODEX_ACP_PATH` as a temporary compatibility fallback to the same `codex` binary path during migration
134133

135134
## OpenClaw Runtime

docs/config.zh.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ bootstrap manager 当前固定使用 `picoclaw_sandbox`;`openclaw_sandbox` 支
127127

128128
当 worker 使用 Codex runtime 时,CSGClaw 会通过 `codex app-server --listen stdio://` 启动本地 `codex` CLI。你可以通过下面的环境变量覆盖二进制查找行为:
129129

130-
- `CSGCLAW_CODEX_PATH`:指定本地 `codex` 可执行文件路径。Windows 下优先指向 npm 的 `codex.cmd` shim 或原生 `codex.exe`,不要指向 `codex.ps1`
130+
- `CSGCLAW_CODEX_PATH`:指定本地 `codex` 可执行文件路径。Windows 支持 npm 的 `codex.cmd`/`codex.bat` shim 和原生 `codex.exe`;CSGClaw 会通过 `cmd.exe` 启动命令 shim,但不支持 `codex.ps1`
131131
- `CSGCLAW_CODEX_ACP_PATH`:迁移期间的兼容回退项,也指向同一个 `codex` 可执行文件路径
132132

133133
## OpenClaw Runtime

internal/api/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ func codexInstallGuidance(goos string) string {
550550
case "darwin":
551551
return "Install the Codex CLI for macOS, or set CSGCLAW_CODEX_PATH to the codex binary."
552552
case "windows":
553-
return "Install the Codex CLI for Windows, or set CSGCLAW_CODEX_PATH to codex.exe."
553+
return "Install the Codex CLI for Windows, or set CSGCLAW_CODEX_PATH to codex.exe or the npm codex.cmd shim."
554554
case "linux":
555555
return "Install the Codex CLI for Linux, or set CSGCLAW_CODEX_PATH to the codex binary."
556556
default:

internal/codexcli/command.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package codexcli
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
func isWindowsCommandShimPath(path string) bool {
10+
switch strings.ToLower(filepath.Ext(strings.TrimSpace(path))) {
11+
case ".cmd", ".bat":
12+
return true
13+
default:
14+
return false
15+
}
16+
}
17+
18+
// windowsBatchAppServerCommandLine builds the exact command line consumed by
19+
// cmd.exe. App-server arguments are fixed, so only the validated shim path is
20+
// interpolated into the shell command.
21+
func windowsBatchAppServerCommandLine(binaryPath string) (string, error) {
22+
binaryPath = strings.TrimSpace(binaryPath)
23+
if binaryPath == "" {
24+
return "", fmt.Errorf("Codex command shim path is required")
25+
}
26+
if strings.ContainsAny(binaryPath, "\x00\r\n\"") || strings.Contains(binaryPath, "%") {
27+
return "", fmt.Errorf("Codex command shim path %q contains characters that cannot be passed safely to cmd.exe", binaryPath)
28+
}
29+
return `/d /s /v:off /c ""` + binaryPath + `" ` + strings.Join(AppServerArgs(), " ") + `"`, nil
30+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !windows
2+
3+
package codexcli
4+
5+
import (
6+
"context"
7+
"os/exec"
8+
)
9+
10+
func AppServerCommandContext(ctx context.Context, binaryPath string) (*exec.Cmd, error) {
11+
return exec.CommandContext(ctx, binaryPath, AppServerArgs()...), nil
12+
}

internal/codexcli/command_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package codexcli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestWindowsBatchAppServerCommandLine(t *testing.T) {
9+
path := `C:\Users\Jane Doe\AppData\Roaming\npm & tools\codex.cmd`
10+
got, err := windowsBatchAppServerCommandLine(path)
11+
if err != nil {
12+
t.Fatalf("windowsBatchAppServerCommandLine() error = %v", err)
13+
}
14+
want := `/d /s /v:off /c ""C:\Users\Jane Doe\AppData\Roaming\npm & tools\codex.cmd" app-server --listen stdio://"`
15+
if got != want {
16+
t.Fatalf("windowsBatchAppServerCommandLine() = %q, want %q", got, want)
17+
}
18+
}
19+
20+
func TestWindowsBatchAppServerCommandLineRejectsUnsafePath(t *testing.T) {
21+
for _, path := range []string{"", `C:\npm\%USER%\codex.cmd`, "C:\\npm\\codex.cmd\r\nwhoami"} {
22+
t.Run(strings.ReplaceAll(path, "\\", "_"), func(t *testing.T) {
23+
if _, err := windowsBatchAppServerCommandLine(path); err == nil {
24+
t.Fatalf("windowsBatchAppServerCommandLine(%q) error = nil, want error", path)
25+
}
26+
})
27+
}
28+
}
29+
30+
func TestIsWindowsCommandShimPath(t *testing.T) {
31+
for _, path := range []string{`C:\npm\codex.cmd`, `C:\npm\CODEX.BAT`} {
32+
if !isWindowsCommandShimPath(path) {
33+
t.Fatalf("isWindowsCommandShimPath(%q) = false, want true", path)
34+
}
35+
}
36+
for _, path := range []string{`C:\npm\codex.exe`, `C:\npm\codex.ps1`, "/usr/bin/codex"} {
37+
if isWindowsCommandShimPath(path) {
38+
t.Fatalf("isWindowsCommandShimPath(%q) = true, want false", path)
39+
}
40+
}
41+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//go:build windows
2+
3+
package codexcli
4+
5+
import (
6+
"context"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
"syscall"
11+
)
12+
13+
func AppServerCommandContext(ctx context.Context, binaryPath string) (*exec.Cmd, error) {
14+
if !isWindowsCommandShimPath(binaryPath) {
15+
return exec.CommandContext(ctx, binaryPath, AppServerArgs()...), nil
16+
}
17+
commandLine, err := windowsBatchAppServerCommandLine(binaryPath)
18+
if err != nil {
19+
return nil, err
20+
}
21+
commandProcessor := strings.TrimSpace(os.Getenv("ComSpec"))
22+
if commandProcessor == "" {
23+
commandProcessor = "cmd.exe"
24+
}
25+
cmd := exec.CommandContext(ctx, commandProcessor)
26+
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: commandLine}
27+
return cmd, nil
28+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//go:build windows
2+
3+
package codexcli
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
)
13+
14+
func TestAppServerCommandContextRunsWindowsCommandShim(t *testing.T) {
15+
dir := filepath.Join(t.TempDir(), "npm shim & (test)")
16+
if err := os.MkdirAll(dir, 0o755); err != nil {
17+
t.Fatalf("MkdirAll() error = %v", err)
18+
}
19+
shimPath := filepath.Join(dir, "codex.cmd")
20+
shim := "@echo off\r\nset /p input=\r\necho %1^|%2^|%3^|%input%\r\n"
21+
if err := os.WriteFile(shimPath, []byte(shim), 0o755); err != nil {
22+
t.Fatalf("WriteFile() error = %v", err)
23+
}
24+
25+
cmd, err := AppServerCommandContext(context.Background(), shimPath)
26+
if err != nil {
27+
t.Fatalf("AppServerCommandContext() error = %v", err)
28+
}
29+
cmd.Stdin = strings.NewReader("ping\n")
30+
var stdout, stderr bytes.Buffer
31+
cmd.Stdout = &stdout
32+
cmd.Stderr = &stderr
33+
if err := cmd.Run(); err != nil {
34+
t.Fatalf("Run() error = %v; stderr=%s", err, stderr.String())
35+
}
36+
if got, want := strings.TrimSpace(stdout.String()), "app-server|--listen|stdio://|ping"; got != want {
37+
t.Fatalf("stdout = %q, want %q", got, want)
38+
}
39+
}

0 commit comments

Comments
 (0)