diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 0b483dc386..f0e9b44a5a 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -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) diff --git a/coderd/x/chatd/chattool/execute_test.go b/coderd/x/chatd/chattool/execute_test.go index 0ff98dd1e6..3e68396925 100644 --- a/coderd/x/chatd/chattool/execute_test.go +++ b/coderd/x/chatd/chattool/execute_test.go @@ -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) { diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 230dc3e74e..47a475786c 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -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