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
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1909-windows-git-bash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: Run native Windows commands in Git Bash
pr: 1909
surface: [chat, engine]
invalidates:
- "Native Windows commands previously selected the WSL launcher and parsed variables a second time in Linux. Native commands now select Git Bash, preserve shell variables and Windows file access, and omit the shell tool when no usable bash is installed."
- "Git Bash background jobs and detached services inherited append-only stdout handles and could exit silently. Windows logs now open writable at their end; Unix append flags and command arguments stay unchanged."
- "The Windows SQLite URI fix already covered graph, workspace and automation stores, but plan writers and spending readers still built raw file URIs. They now share the existing formatter on Windows while Unix plan readers retain the writer's raw filename bytes."
---

Install Git for Windows to run commands with the native Windows build, or choose an MSYS2 bash with CODEAF_BASH. Running the Linux build inside WSL continues to use Linux bash. The manual and project facts explain the shell requirement and Windows drive paths.
38 changes: 38 additions & 0 deletions internal/automation/store_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build windows

package automation

import (
"path/filepath"
"testing"
"time"
)

// N2 writes and reopens an automation in a native Windows folder whose spaces,
// Unicode, fragment and percent characters must remain part of the filename.
func TestWindowsAutomationStoreNativePath(t *testing.T) {
root := filepath.Join(t.TempDir(), "dir with space é", "automations #%25")
s, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer func() {
if s != nil {
_ = s.Close()
}
}()
item, err := s.Create(Automation{Title: "héllo", Workspace: t.TempDir(), Schedule: Schedule{At: time.Now().Add(time.Hour)}, Action: Action{Say: "hello"}})
if err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
s, err = Open(root)
if err != nil {
t.Fatal(err)
}
if got, err := s.Get(item.ID); err != nil || got.Title != "héllo" {
t.Fatalf("automation = %+v, %v", got, err)
}
}
4 changes: 4 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,10 @@ var OperatorEnvPins = []string{
// CODEAF_FURROW names a furrow to use instead of the one codeaf carries
// (internal/furrow). A path to a program is plumbing.
"CODEAF_FURROW",
// CODEAF_BASH names the bash native Windows runs commands in when Git for
// Windows is not where codeaf looks (internal/shell). A path to a program
// is plumbing for the same reason CODEAF_FURROW is.
"CODEAF_BASH",
// The three site-attribution pins — a URL, an app name, a category list —
// used to sit here, and they are gone rather than moved: the OpenRouter app
// this binary reports as is decided in internal/provider from the binary's
Expand Down
45 changes: 45 additions & 0 deletions internal/exec/bare/shell_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//go:build windows

package bare

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

"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/shell"
)

// N1 makes a shell with no implementation absent from both public inventories,
// and direct calls explain the setup instead of creating a process.
func TestWindowsMissingBashIsAbsent(t *testing.T) {
dir := t.TempDir()
t.Setenv("CODEAF_HOME", dir)
for _, name := range []string{"ProgramW6432", "ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA", "USERPROFILE", env.Legacy("CODEAF_BASH")} {
t.Setenv(name, "")
}
t.Setenv("PATH", filepath.Join(os.Getenv("SystemRoot"), "System32"))
for _, explicit := range []string{filepath.Join(dir, "missing.exe"), ""} {
t.Setenv("CODEAF_BASH", explicit)
for _, tools := range [][]Tool{AllTools(dir), Tools(dir)} {
for _, tool := range tools {
if tool.Name == "bash" {
t.Fatal("unusable bash is advertised")
}
}
}
output, failed, err := newBashTool(dir, DefaultCaps()).Execute(context.Background(), []byte(`{"command":"echo WRONG > marker"}`))
if err != nil || !failed || !strings.Contains(output, "CODEAF_BASH") {
t.Fatalf("direct call: %q %v %v", output, failed, err)
}
if explicit == "" && output != shell.NoBash {
t.Fatalf("missing discovery remedy: %q, want %q", output, shell.NoBash)
}
if _, err := os.Stat(filepath.Join(dir, "marker")); !os.IsNotExist(err) {
t.Fatalf("command ran: %v", err)
}
}
}
16 changes: 12 additions & 4 deletions internal/exec/bare/streaming.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ package bare
import (
"os"
"os/exec"
"runtime"
"strings"
"sync"

Expand Down Expand Up @@ -92,13 +93,16 @@ func TailForResultAt(text string, caps Caps) string { return truncateTail(text,
// reads while it runs — the foreground bash tool here, and the background job
// registry in internal/session, which used to keep a hand-copied three-line
// version of the choice and now asks this instead.
func StreamingShell(command string) (string, []string) {
shell, shellArgs := getShellConfig()
func StreamingShell(command string) (string, []string, error) {
shell, shellArgs, err := getShellConfig()
if err != nil {
return "", nil, err
}
full := append(append([]string{}, shellArgs...), command)
if prefix := stdbufPrefix(); len(prefix) > 0 {
return prefix[0], append(append(append([]string{}, prefix[1:]...), shell), full...)
return prefix[0], append(append(append([]string{}, prefix[1:]...), shell), full...), nil
}
return shell, full
return shell, full, nil
}

// StreamingEnv is the environment such a command runs in: the person's own,
Expand Down Expand Up @@ -139,6 +143,10 @@ func StreamingEnv() []string {
// run once against `true`, and it is taken only if it exits clean and says
// nothing at all.
var stdbufPrefix = sync.OnceValue(func() []string {
// A native Windows child must be launched directly, without an MSYS wrapper.
if runtime.GOOS == "windows" {
return nil
}
path, err := exec.LookPath("stdbuf")
if err != nil {
return nil
Expand Down
43 changes: 28 additions & 15 deletions internal/exec/bare/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/processgroup"
"github.com/Agent-Field/codeaf/internal/shell"
)

// Tool is the shape every tool in this package wears, and the shape a
Expand Down Expand Up @@ -53,15 +54,15 @@ func AllTools(cwd string) []Tool { return AllToolsCapped(cwd, DefaultCaps()) }
// from the pair they are actually applying.
func AllToolsCapped(cwd string, caps Caps) []Tool {
caps = caps.resolve()
return []Tool{
return availableTools([]Tool{
newReadTool(cwd, caps),
newBashTool(cwd, caps),
newEditTool(cwd),
newWriteTool(cwd),
newGrepTool(cwd, caps),
newFindTool(cwd, caps),
newLsTool(cwd, caps),
}
})
}

// Tools returns the four default active pi tools in registry order: read,
Expand All @@ -74,12 +75,12 @@ func Tools(cwd string) []Tool { return ToolsCapped(cwd, DefaultCaps()) }
// ToolsCapped is [Tools] with the belt's own result caps. See [AllToolsCapped].
func ToolsCapped(cwd string, caps Caps) []Tool {
caps = caps.resolve()
return []Tool{
return availableTools([]Tool{
newReadTool(cwd, caps),
newBashTool(cwd, caps),
newEditTool(cwd),
newWriteTool(cwd),
}
})
}

// ── wire schemas (verbatim from pi) ────────────────────────────────────────
Expand Down Expand Up @@ -461,7 +462,10 @@ func newBashTool(cwd string, caps Caps) Tool {
// held in a 4KB buffer until it exits is a command that looks dead to
// anybody reading its log, and a long one gets killed for it
// (streaming.go states the whole case).
shell, shellArgs := StreamingShell(p.Command)
shell, shellArgs, shellErr := StreamingShell(p.Command)
if shellErr != nil {
return shellErr.Error(), true, nil
}

// THE COMMAND IS NOT BOUND TO THE CONTEXT, and the watcher below
// does the binding by hand. exec.CommandContext's own watcher kills
Expand Down Expand Up @@ -707,16 +711,10 @@ func exitCodeFromWait(err error) int {
}

// getShellConfig mirrors pi's shell.js:getShellConfig on Unix: /bin/bash if
// it exists, then bash on PATH, then fallback to sh. Returns the shell path
// and args (["-c"]).
func getShellConfig() (string, []string) {
if _, err := os.Stat("/bin/bash"); err == nil {
return "/bin/bash", []string{"-c"}
}
if bash, err := exec.LookPath("bash"); err == nil {
return bash, []string{"-c"}
}
return "sh", []string{"-c"}
// it exists, then bash on PATH, then fallback to sh. Windows instead uses the
// engine's native bash, and refuses before a command can reach a WSL launcher.
func getShellConfig() (string, []string, error) {
return shell.Preferred()
}

// killProcessGroup sends SIGKILL to the process group, mirroring pi's
Expand Down Expand Up @@ -1273,3 +1271,18 @@ func commandCutWords(began time.Time, text string) string {
}
return head + "\n" + text
}

// availableTools omits a shell with no native implementation, so the model
// cannot select a command that will fail on every call. Unix is unchanged.
func availableTools(tools []Tool) []Tool {
if _, err := shell.Bash(); err == nil {
return tools
}
out := tools[:0]
for _, tool := range tools {
if tool.Name != "bash" {
out = append(out, tool)
}
}
return out
}
26 changes: 23 additions & 3 deletions internal/exec/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
Expand All @@ -17,6 +18,8 @@ import (
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/processgroup"
"github.com/Agent-Field/codeaf/internal/rtk"
commandshell "github.com/Agent-Field/codeaf/internal/shell"
"github.com/Agent-Field/codeaf/internal/store"
)

Expand Down Expand Up @@ -132,6 +135,10 @@ func newJobRegistry(workspace *Workspace, leaf string, results int) *jobRegistry
// startBackground starts a shell with file-backed output. Because no parent
// pipe is involved, a verbose build cannot block on a reader in the executor.
func (t *Toolbox) startBackground(ctx context.Context, command string, args map[string]any) Result {
bash, shellErr := commandshell.Bash()
if shellErr != nil {
return errorf("%s", shellErr)
}
duration, err := backgroundDuration(ctx, args)
if err != nil {
return errorf("could not start background job: %v", err)
Expand All @@ -143,7 +150,20 @@ func (t *Toolbox) startBackground(ctx context.Context, command string, args map[
environment = os.Environ()
environment = replaceEnv(environment, "CODEAF_SKILLS_BIN", bin)
environment = replaceEnv(environment, env.Legacy("CODEAF_SKILLS_BIN"), bin)
command = "export PATH=\"${CODEAF_SKILLS_BIN:?}:$PATH\"\n" + command
command = commandshell.PathExport("CODEAF_SKILLS_BIN", bin) + command
}
}
// Windows jobs need the native rtk shelf on their own shell PATH too.
// No rewriting is applied: a background job keeps its ordinary output.
if runtime.GOOS == "windows" {
if tool, ok := rtk.Available(); ok {
if environment == nil {
environment = os.Environ()
}
bin := filepath.Dir(tool.Path)
environment = replaceEnv(environment, "CODEAF_RTK_BIN", bin)
environment = replaceEnv(environment, env.Legacy("CODEAF_RTK_BIN"), bin)
command = commandshell.PathExport("CODEAF_RTK_BIN", bin) + command
}
}
// A BACKGROUND JOB IS STILL A MODEL'S COMMAND, and its shell must not reach
Expand All @@ -167,13 +187,13 @@ func (t *Toolbox) startBackground(ctx context.Context, command string, args map[
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return errorf("could not create background log directory: %v", err)
}
logFile, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
logFile, err := commandshell.OpenLog(full, 0o644)
if err != nil {
return errorf("could not create background log %s: %v", relative, err)
}

jobCtx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(jobCtx, "bash", "-lc", command)
cmd := exec.CommandContext(jobCtx, bash, "-lc", command)
configureDetachedCommand(cmd, r.workspace.Root(), logFile)
cmd.Env = environment
cmd.Cancel = func() error {
Expand Down
14 changes: 7 additions & 7 deletions internal/exec/jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -99,6 +98,7 @@ func TestBackgroundStartReturnsImmediatelyAndCreatesDurableLog(t *testing.T) {
}
}

// N2 and N3 keep service ownership and cleanup through native process probes.
func TestKeepTransfersOwnershipAndLeafTeardownCountsOnlyKilledJobs(t *testing.T) {
tools, _ := backgroundToolbox(t)
for index := 0; index < 2; index++ {
Expand All @@ -118,11 +118,11 @@ func TestKeepTransfersOwnershipAndLeafTeardownCountsOnlyKilledJobs(t *testing.T)
if killed := tools.Close(); killed != 1 {
t.Fatalf("leaf teardown killed %d jobs, want only the unpromoted job", killed)
}
if err := syscall.Kill(requests[0].PID, 0); err != nil {
if err := testProcessSignal(requests[0].PID, 0); err != nil {
t.Fatalf("requested service did not survive leaf teardown: %v", err)
}
requests[0].Stop()
if err := syscall.Kill(requests[0].PID, 0); err == nil {
if err := testProcessSignal(requests[0].PID, 0); err == nil {
t.Fatal("declined service process still alive")
}
}
Expand Down Expand Up @@ -253,10 +253,10 @@ func TestJobKillTerminatesTheWholeProcessGroup(t *testing.T) {
t.Fatalf("kill result = %+v", result)
}
deadline := time.Now().Add(slack(3 * time.Second))
for time.Now().Before(deadline) && syscall.Kill(childPID, 0) == nil {
for time.Now().Before(deadline) && testProcessSignal(childPID, 0) == nil {
time.Sleep(20 * time.Millisecond)
}
if err := syscall.Kill(childPID, 0); err == nil {
if err := testProcessSignal(childPID, 0); err == nil {
t.Fatalf("grandchild %d survived process-group kill", childPID)
}
}
Expand All @@ -273,7 +273,7 @@ func TestExitedBackgroundShellDoesNotLeaveDetachedChildren(t *testing.T) {
t.Fatalf("child pid %q: %v", pidText, err)
}
waitForJobDone(t, tools, 1, 5*time.Second)
if err := syscall.Kill(childPID, 0); err == nil {
if err := testProcessSignal(childPID, 0); err == nil {
t.Fatalf("detached child %d survived its background shell", childPID)
}
}
Expand Down Expand Up @@ -482,7 +482,7 @@ func TestSchedulerAbandonmentTearsDownLeafJobs(t *testing.T) {
t.Fatalf("abandoned process did not start: %v; log=%q", err, logBody)
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(pidBody)))
if err := syscall.Kill(pid, 0); err == nil {
if err := testProcessSignal(pid, 0); err == nil {
t.Fatalf("process %d survived scheduler abandonment", pid)
}
}
Expand Down
9 changes: 9 additions & 0 deletions internal/exec/process_signal_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package exec

import "syscall"

// C10 keeps the existing Unix signal probes unchanged while allowing the native
// Windows contract tests to compile in the same test package.
func testProcessSignal(pid int, signal syscall.Signal) error { return syscall.Kill(pid, signal) }
Loading
Loading