feat: explain missing POSIX sh on Windows workspaces (#27048)

When a workspace has no POSIX sh on PATH (typical for fresh Windows workspaces), the execute tool fails with a raw `exec: "sh": executable file not found in %PATH%` error the model cannot act on. 

This change:
- Enriches the above error in chattool with remediation steps and a docs link.
- Documents the requirement in the Coder Agents architecture page.

> This PR was generated by Coder Agents on behalf of @johnstcn
This commit is contained in:
Cian Johnston
2026-07-07 21:32:27 +00:00
committed by GitHub
parent 283e463de1
commit 14767dfa8e
3 changed files with 101 additions and 2 deletions
+27 -2
View File
@@ -49,6 +49,31 @@ var fileDumpPatterns = []*regexp.Regexp{
regexp.MustCompile(`^(rg|grep)\s+-l\s+`),
}
const (
// shNotFoundFragment omits the trailing path variable
// (%PATH% vs $PATH) for OS portability. Only transport
// errors from StartProcess contain it, never command output.
shNotFoundFragment = `exec: "sh": executable file not found`
// shNotFoundGuidance is model-facing remediation text, relayed
// to the user. Keep the docs anchor in sync with
// docs/ai-coder/agents/architecture.md.
shNotFoundGuidance = "The workspace has no POSIX shell (sh) on its PATH. " +
"Coder Agents run commands with \"sh -c\". On Windows, install sh " +
"via Git Bash, MSYS2, or WSL, then restart the workspace to pick " +
"up the updated PATH. See " +
"https://coder.com/docs/ai-coder/agents/architecture#windows-workspace-shell-requirement"
)
// enrichStartError appends actionable guidance when a StartProcess
// error indicates the workspace has no sh binary.
func enrichStartError(msg string) string {
if strings.Contains(msg, shNotFoundFragment) {
return msg + "\n\n" + shNotFoundGuidance
}
return msg
}
// ExecuteResult is the structured response from the execute
// tool.
type ExecuteResult struct {
@@ -162,7 +187,7 @@ func executeBackground(
Background: true,
})
if err != nil {
return errorResult(fmt.Sprintf("start background process: %v", err))
return errorResult(enrichStartError(fmt.Sprintf("start background process: %v", err)))
}
result := ExecuteResult{
@@ -212,7 +237,7 @@ func executeForeground(
Background: false,
})
if err != nil {
return errorResult(fmt.Sprintf("start process: %v", err))
return errorResult(enrichStartError(fmt.Sprintf("start process: %v", err)))
}
result := waitForProcess(cmdCtx, ctx, conn, resp.ID, timeout)
+66
View File
@@ -408,6 +408,72 @@ func TestExecuteTool(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
assert.False(t, result.Success)
assert.Contains(t, result.Error, "connection lost")
// Unrelated errors must not trigger the missing-shell
// guidance.
assert.NotContains(t, result.Error, "Git Bash")
assert.NotContains(t, result.Error, "coder.com/docs")
})
t.Run("MissingShellError", func(t *testing.T) {
t.Parallel()
// OS rendering differs (%PATH% vs $PATH); the fragment
// omits the suffix to match both.
tests := []struct {
name string
input string
agentErr string
wantPrefix string
}{
{
name: "ForegroundWindows",
input: `{"command":"echo hi"}`,
agentErr: "unexpected status code 500: Failed to start process.\n\tError: start process: exec: \"sh\": executable file not found in %PATH%",
wantPrefix: "start process:",
},
{
name: "BackgroundWindows",
input: `{"command":"echo hi","run_in_background":true}`,
agentErr: "unexpected status code 500: Failed to start process.\n\tError: start process: exec: \"sh\": executable file not found in %PATH%",
wantPrefix: "start background process:",
},
{
name: "ForegroundPOSIX",
input: `{"command":"echo hi"}`,
agentErr: "unexpected status code 500: Failed to start process.\n\tError: start process: exec: \"sh\": executable file not found in $PATH",
wantPrefix: "start process:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
StartProcess(gomock.Any(), gomock.Any()).
Return(workspacesdk.StartProcessResponse{}, xerrors.New(tt.agentErr))
tool := newExecuteTool(t, mockConn)
ctx := testutil.Context(t, testutil.WaitMedium)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-1",
Name: "execute",
Input: tt.input,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
var result chattool.ExecuteResult
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
assert.False(t, result.Success)
// The result keeps the original error for debugging.
assert.Contains(t, result.Error, tt.wantPrefix)
assert.Contains(t, result.Error, `exec: "sh": executable file not found`)
assert.Contains(t, result.Error, "Git Bash")
assert.Contains(t, result.Error, "https://coder.com/docs/ai-coder/agents/architecture#windows-workspace-shell-requirement")
})
}
})
t.Run("ProcessOutputError", func(t *testing.T) {
+8
View File
@@ -143,6 +143,14 @@ They traverse the same Tailnet tunnel used by web terminals and IDE connections.
| `process_signal` | Sends a signal (SIGTERM or SIGKILL) to a tracked process. |
| `attach_file` | Attach a workspace file to the current chat so the user can download it directly from the conversation. Use this when the user should receive an artifact such as a screenshot, log, patch, or document. Pass an absolute file path. The file must already exist in the workspace. |
#### Windows workspace shell requirement
Coder Agents run workspace commands through a POSIX-compatible shell (`sh`).
Windows workspaces must provide `sh` on `PATH`, for example from
[Git for Windows](https://gitforwindows.org/) (Git Bash), MSYS2, or WSL.
PowerShell alone cannot run the `execute` tool. After installing a
shell, restart the workspace to pick up the updated `PATH`.
### Platform tools
These tools run entirely within the control plane. They do not require a