mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user