Skip to content
Merged
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
3 changes: 1 addition & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,7 @@ Notes:

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

### `csgclaw user`

Expand Down
2 changes: 1 addition & 1 deletion docs/cli.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ csgclaw agent delete --all --force
说明:

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

### `csgclaw user`

Expand Down
3 changes: 1 addition & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ For complete Codex worker profiles, CSGClaw writes `~/.csgclaw/agents/<agent-nam
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:

- `CSGCLAW_CODEX_PATH` to point at a preinstalled `codex` binary.
On Windows, point it at a native `codex.exe`.
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.
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.
- `CSGCLAW_CODEX_ACP_PATH` as a temporary compatibility fallback to the same `codex` binary path during migration

## OpenClaw Runtime
Expand Down
2 changes: 1 addition & 1 deletion docs/config.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ bootstrap manager 当前固定使用 `picoclaw_sandbox`;`openclaw_sandbox` 支

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

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

## OpenClaw Runtime
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ func codexInstallGuidance(goos string) string {
case "darwin":
return "Install the Codex CLI for macOS, or set CSGCLAW_CODEX_PATH to the codex binary."
case "windows":
return "Install the Codex CLI for Windows, or set CSGCLAW_CODEX_PATH to codex.exe."
return "Install the Codex CLI for Windows, or set CSGCLAW_CODEX_PATH to codex.exe or the npm codex.cmd shim."
case "linux":
return "Install the Codex CLI for Linux, or set CSGCLAW_CODEX_PATH to the codex binary."
default:
Expand Down
30 changes: 30 additions & 0 deletions internal/codexcli/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package codexcli

import (
"fmt"
"path/filepath"
"strings"
)

func isWindowsCommandShimPath(path string) bool {
switch strings.ToLower(filepath.Ext(strings.TrimSpace(path))) {
case ".cmd", ".bat":
return true
default:
return false
}
}

// windowsBatchAppServerCommandLine builds the exact command line consumed by
// cmd.exe. App-server arguments are fixed, so only the validated shim path is
// interpolated into the shell command.
func windowsBatchAppServerCommandLine(binaryPath string) (string, error) {
binaryPath = strings.TrimSpace(binaryPath)
if binaryPath == "" {
return "", fmt.Errorf("Codex command shim path is required")
}
if strings.ContainsAny(binaryPath, "\x00\r\n\"") || strings.Contains(binaryPath, "%") {
return "", fmt.Errorf("Codex command shim path %q contains characters that cannot be passed safely to cmd.exe", binaryPath)
}
return `/d /s /v:off /c ""` + binaryPath + `" ` + strings.Join(AppServerArgs(), " ") + `"`, nil
}
12 changes: 12 additions & 0 deletions internal/codexcli/command_nonwindows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !windows

package codexcli

import (
"context"
"os/exec"
)

func AppServerCommandContext(ctx context.Context, binaryPath string) (*exec.Cmd, error) {
return exec.CommandContext(ctx, binaryPath, AppServerArgs()...), nil
}
41 changes: 41 additions & 0 deletions internal/codexcli/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package codexcli

import (
"strings"
"testing"
)

func TestWindowsBatchAppServerCommandLine(t *testing.T) {
path := `C:\Users\Jane Doe\AppData\Roaming\npm & tools\codex.cmd`
got, err := windowsBatchAppServerCommandLine(path)
if err != nil {
t.Fatalf("windowsBatchAppServerCommandLine() error = %v", err)
}
want := `/d /s /v:off /c ""C:\Users\Jane Doe\AppData\Roaming\npm & tools\codex.cmd" app-server --listen stdio://"`
if got != want {
t.Fatalf("windowsBatchAppServerCommandLine() = %q, want %q", got, want)
}
}

func TestWindowsBatchAppServerCommandLineRejectsUnsafePath(t *testing.T) {
for _, path := range []string{"", `C:\npm\%USER%\codex.cmd`, "C:\\npm\\codex.cmd\r\nwhoami"} {
t.Run(strings.ReplaceAll(path, "\\", "_"), func(t *testing.T) {
if _, err := windowsBatchAppServerCommandLine(path); err == nil {
t.Fatalf("windowsBatchAppServerCommandLine(%q) error = nil, want error", path)
}
})
}
}

func TestIsWindowsCommandShimPath(t *testing.T) {
for _, path := range []string{`C:\npm\codex.cmd`, `C:\npm\CODEX.BAT`} {
if !isWindowsCommandShimPath(path) {
t.Fatalf("isWindowsCommandShimPath(%q) = false, want true", path)
}
}
for _, path := range []string{`C:\npm\codex.exe`, `C:\npm\codex.ps1`, "/usr/bin/codex"} {
if isWindowsCommandShimPath(path) {
t.Fatalf("isWindowsCommandShimPath(%q) = true, want false", path)
}
}
}
28 changes: 28 additions & 0 deletions internal/codexcli/command_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build windows

package codexcli

import (
"context"
"os"
"os/exec"
"strings"
"syscall"
)

func AppServerCommandContext(ctx context.Context, binaryPath string) (*exec.Cmd, error) {
if !isWindowsCommandShimPath(binaryPath) {
return exec.CommandContext(ctx, binaryPath, AppServerArgs()...), nil
}
commandLine, err := windowsBatchAppServerCommandLine(binaryPath)
if err != nil {
return nil, err
}
commandProcessor := strings.TrimSpace(os.Getenv("ComSpec"))
if commandProcessor == "" {
commandProcessor = "cmd.exe"
}
cmd := exec.CommandContext(ctx, commandProcessor)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: commandLine}
return cmd, nil
}
39 changes: 39 additions & 0 deletions internal/codexcli/command_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//go:build windows

package codexcli

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)

func TestAppServerCommandContextRunsWindowsCommandShim(t *testing.T) {
dir := filepath.Join(t.TempDir(), "npm shim & (test)")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
shimPath := filepath.Join(dir, "codex.cmd")
shim := "@echo off\r\nset /p input=\r\necho %1^|%2^|%3^|%input%\r\n"
if err := os.WriteFile(shimPath, []byte(shim), 0o755); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

cmd, err := AppServerCommandContext(context.Background(), shimPath)
if err != nil {
t.Fatalf("AppServerCommandContext() error = %v", err)
}
cmd.Stdin = strings.NewReader("ping\n")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("Run() error = %v; stderr=%s", err, stderr.String())
}
if got, want := strings.TrimSpace(stdout.String()), "app-server|--listen|stdio://|ping"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
48 changes: 45 additions & 3 deletions internal/codexcli/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,54 @@ func TestInstallerEnsureInstallsWindowsExecutable(t *testing.T) {
assertFileContent(t, target, string(testPEBinary("arm64")))
}

func TestInstallerEnsureUsesManagedExecutableForWindowsCommandShim(t *testing.T) {
func TestInstallerEnsureUsesExistingWindowsCommandShim(t *testing.T) {
dir := t.TempDir()
shimPath := writeExecutable(t, filepath.Join(dir, "npm", "codex.cmd"), "@echo off\n")
managedPath := filepath.Join(dir, "managed", "codex.exe")
t.Setenv(EnvBinaryPath, shimPath)
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.URL.Path != "/windows/amd64" || r.URL.Query().Get("package") != "codex-cli" {
t.Errorf("request URL = %s, want /windows/amd64?package=codex-cli", r.URL.String())
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(testPEBinary("amd64"))
}))
defer server.Close()

installer := NewInstaller(InstallerOptions{
Locator: Locator{
ManagedPath: managedPath,
LookPath: func(string) (string, error) {
return "", os.ErrNotExist
},
},
BaseURL: server.URL,
GOOS: "windows",
GOARCH: "amd64",
})
status, err := installer.Ensure(context.Background())
if err != nil {
t.Fatalf("Ensure() error = %v", err)
}
if !status.Installed || status.Path != shimPath {
t.Fatalf("Ensure() status = %+v, want installed command shim at %q", status, shimPath)
}
if got := requests.Load(); got != 0 {
t.Fatalf("download requests = %d, want 0", got)
}
if _, statErr := os.Stat(managedPath); !os.IsNotExist(statErr) {
t.Fatalf("Stat(%q) error = %v, want no managed executable", managedPath, statErr)
}
assertFileContent(t, shimPath, "@echo off\n")
}

func TestInstallerEnsureUsesManagedExecutableForMissingWindowsCommandShim(t *testing.T) {
dir := t.TempDir()
shimPath := filepath.Join(dir, "npm", "codex.cmd")
managedPath := filepath.Join(dir, "managed", "codex.exe")
t.Setenv(EnvBinaryPath, shimPath)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/windows/amd64" || r.URL.Query().Get("package") != "codex-cli" {
t.Errorf("request URL = %s, want /windows/amd64?package=codex-cli", r.URL.String())
Expand All @@ -163,10 +206,9 @@ func TestInstallerEnsureUsesManagedExecutableForWindowsCommandShim(t *testing.T)
t.Fatalf("Ensure() error = %v", err)
}
if !status.Installed || status.Path != managedPath {
t.Fatalf("Ensure() status = %+v, want installed at %q", status, managedPath)
t.Fatalf("Ensure() status = %+v, want managed executable at %q", status, managedPath)
}
assertFileContent(t, managedPath, string(testPEBinary("amd64")))
assertFileContent(t, shimPath, "@echo off\n")
}

func TestInstallerEnsureCoalescesConcurrentDownloads(t *testing.T) {
Expand Down
30 changes: 17 additions & 13 deletions internal/codexcli/locator.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,18 @@ func (l Locator) Locate() (string, error) {
} else if ok {
return path, nil
}
path, ok, err := l.executablePath(explicit)
if err != nil {
return "", err
}
if ok {
return path, nil
}
if !l.isWindowsCommandShim(explicit) {
path, ok, err := l.executablePath(explicit)
if err != nil {
return "", err
}
if ok {
return path, nil
}
return "", fmt.Errorf("codex binary %s: %w", explicit, os.ErrNotExist)
}
// Batch shims cannot be passed directly to CreateProcess. Continue to a
// native PATH or managed executable so startup installation can recover.
// A missing command shim can still recover through PATH or the managed
// native executable installed by CSGClaw.
}
if lookPath := l.lookPath(); lookPath != nil {
for _, name := range l.binaryNames() {
Expand Down Expand Up @@ -130,10 +130,14 @@ func (l Locator) executablePath(path string) (string, bool, error) {
return "", false, nil
}
if l.isWindows() {
if !strings.EqualFold(filepath.Ext(path), ".exe") {
return "", false, fmt.Errorf("codex binary %s is a script shim; set %s to a native codex.exe file", path, EnvBinaryPath)
switch strings.ToLower(filepath.Ext(path)) {
case ".exe", ".cmd", ".bat":
return path, true, nil
case ".ps1":
return "", false, fmt.Errorf("codex binary %s is a PowerShell shim; set %s to codex.cmd, codex.bat, or codex.exe", path, EnvBinaryPath)
default:
return "", false, fmt.Errorf("codex binary %s is not a supported Windows executable; set %s to codex.cmd, codex.bat, or codex.exe", path, EnvBinaryPath)
}
return path, true, nil
}
if info.Mode()&0o111 == 0 {
return "", false, nil
Expand Down Expand Up @@ -181,7 +185,7 @@ func (l Locator) stat(path string) (os.FileInfo, error) {

func (l Locator) binaryNames() []string {
if l.isWindows() {
return []string{BinaryName + ".exe"}
return []string{BinaryName + ".exe", BinaryName + ".cmd", BinaryName + ".bat"}
}
return []string{BinaryName}
}
Expand Down
Loading
Loading