package toolsdk_test import ( "context" "testing" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk/toolsdk" "github.com/coder/coder/v2/testutil" ) func TestWorkspaceBash(t *testing.T) { t.Parallel() t.Run("ValidateArgs", func(t *testing.T) { t.Parallel() deps := toolsdk.Deps{} ctx := context.Background() // Test empty workspace name args := toolsdk.WorkspaceBashArgs{ Workspace: "", Command: "echo test", } _, err := toolsdk.WorkspaceBash.Handler(ctx, deps, args) require.Error(t, err) require.Contains(t, err.Error(), "workspace name cannot be empty") // Test empty command args = toolsdk.WorkspaceBashArgs{ Workspace: "test-workspace", Command: "", } _, err = toolsdk.WorkspaceBash.Handler(ctx, deps, args) require.Error(t, err) require.Contains(t, err.Error(), "command cannot be empty") }) t.Run("ErrorScenarios", func(t *testing.T) { t.Parallel() deps := toolsdk.Deps{} ctx := context.Background() // Test input validation errors (these should fail before client access) t.Run("EmptyWorkspace", func(t *testing.T) { args := toolsdk.WorkspaceBashArgs{ Workspace: "", // Empty workspace should be caught by validation Command: "echo test", } _, err := toolsdk.WorkspaceBash.Handler(ctx, deps, args) require.Error(t, err) require.Contains(t, err.Error(), "workspace name cannot be empty") }) t.Run("EmptyCommand", func(t *testing.T) { args := toolsdk.WorkspaceBashArgs{ Workspace: "test-workspace", Command: "", // Empty command should be caught by validation } _, err := toolsdk.WorkspaceBash.Handler(ctx, deps, args) require.Error(t, err) require.Contains(t, err.Error(), "command cannot be empty") }) }) t.Run("ToolMetadata", func(t *testing.T) { t.Parallel() tool := toolsdk.WorkspaceBash require.Equal(t, toolsdk.ToolNameWorkspaceBash, tool.Name) require.NotEmpty(t, tool.Description) require.Contains(t, tool.Description, "Execute a bash command in a Coder workspace") require.Contains(t, tool.Description, "output is trimmed of leading and trailing whitespace") require.Contains(t, tool.Schema.Required, "workspace") require.Contains(t, tool.Schema.Required, "command") // Check that schema has the required properties require.Contains(t, tool.Schema.Properties, "workspace") require.Contains(t, tool.Schema.Properties, "command") }) t.Run("GenericTool", func(t *testing.T) { t.Parallel() genericTool := toolsdk.WorkspaceBash.Generic() require.Equal(t, toolsdk.ToolNameWorkspaceBash, genericTool.Name) require.NotEmpty(t, genericTool.Description) require.NotNil(t, genericTool.Handler) require.False(t, genericTool.UserClientOptional) }) } func TestNormalizeWorkspaceInput(t *testing.T) { t.Parallel() testCases := []struct { name string input string expected string }{ { name: "SimpleWorkspace", input: "workspace", expected: "workspace", }, { name: "WorkspaceWithAgent", input: "workspace.agent", expected: "workspace.agent", }, { name: "OwnerAndWorkspace", input: "owner/workspace", expected: "owner/workspace", }, { name: "OwnerDashWorkspace", input: "owner--workspace", expected: "owner/workspace", }, { name: "OwnerWorkspaceAgent", input: "owner/workspace.agent", expected: "owner/workspace.agent", }, { name: "OwnerDashWorkspaceAgent", input: "owner--workspace.agent", expected: "owner/workspace.agent", }, { name: "CoderConnectFormat", input: "agent.workspace.owner", // Special Coder Connect reverse format expected: "owner/workspace.agent", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result := toolsdk.NormalizeWorkspaceInput(tc.input) require.Equal(t, tc.expected, result, "Input %q should normalize to %q but got %q", tc.input, tc.expected, result) }) } } func TestAllToolsIncludesBash(t *testing.T) { t.Parallel() // Verify that WorkspaceBash is included in the All slice found := false for _, tool := range toolsdk.All { if tool.Name == toolsdk.ToolNameWorkspaceBash { found = true break } } require.True(t, found, "WorkspaceBash tool should be included in toolsdk.All") } // Note: Unit testing ExecuteCommandWithTimeout is challenging because it expects // a concrete SSH session type. The integration tests above demonstrate the // timeout functionality with a real SSH connection and mock clock. func TestWorkspaceBashTimeout(t *testing.T) { t.Parallel() t.Run("TimeoutDefaultValue", func(t *testing.T) { t.Parallel() // Test that the TimeoutMs field can be set and read correctly args := toolsdk.WorkspaceBashArgs{ TimeoutMs: 0, // Should default to 60000 in handler } // Verify that the TimeoutMs field exists and can be set require.Equal(t, 0, args.TimeoutMs) // Test setting a positive value args.TimeoutMs = 5000 require.Equal(t, 5000, args.TimeoutMs) }) t.Run("TimeoutNegativeValue", func(t *testing.T) { t.Parallel() // Test that negative values can be set and will be handled by the default logic args := toolsdk.WorkspaceBashArgs{ TimeoutMs: -100, } require.Equal(t, -100, args.TimeoutMs) // The actual defaulting to 60000 happens inside the handler // We can't test it without a full integration test setup }) t.Run("TimeoutSchemaValidation", func(t *testing.T) { t.Parallel() tool := toolsdk.WorkspaceBash // Check that timeout_ms is in the schema require.Contains(t, tool.Schema.Properties, "timeout_ms") timeoutProperty := tool.Schema.Properties["timeout_ms"].(map[string]any) require.Equal(t, "integer", timeoutProperty["type"]) require.Equal(t, 60000, timeoutProperty["default"]) require.Equal(t, 1, timeoutProperty["minimum"]) require.Contains(t, timeoutProperty["description"], "timeout in milliseconds") }) t.Run("TimeoutDescriptionUpdated", func(t *testing.T) { t.Parallel() tool := toolsdk.WorkspaceBash // Check that description mentions timeout functionality require.Contains(t, tool.Description, "timeout_ms parameter") require.Contains(t, tool.Description, "defaults to 60000ms") require.Contains(t, tool.Description, "timeout_ms: 30000") }) t.Run("TimeoutCommandScenario", func(t *testing.T) { t.Parallel() // Scenario: echo "123"; sleep 60; echo "456" with 5ms timeout // In this scenario, we'd expect to see "123" in the output and a cancellation message args := toolsdk.WorkspaceBashArgs{ Workspace: "test-workspace", Command: `echo "123"; sleep 60; echo "456"`, // This command would take 60+ seconds TimeoutMs: 5, // 5ms timeout - should timeout after first echo } // Verify the args are structured correctly for the intended test scenario require.Equal(t, "test-workspace", args.Workspace) require.Contains(t, args.Command, `echo "123"`) require.Contains(t, args.Command, "sleep 60") require.Contains(t, args.Command, `echo "456"`) require.Equal(t, 5, args.TimeoutMs) // Note: The actual timeout behavior would need to be tested with a real workspace // This test just verifies the structure is correct for the timeout scenario }) } func TestWorkspaceBashTimeoutIntegration(t *testing.T) { t.Parallel() t.Run("ActualTimeoutBehavior", func(t *testing.T) { t.Parallel() // Scenario: echo "123"; sleep 60; echo "456" with 5s timeout // In this scenario, we'd expect to see "123" in the output and a cancellation message client, workspace, agentToken := setupWorkspaceForAgent(t) // Start the agent and wait for it to be fully ready _ = agenttest.New(t, client.URL, agentToken) // Wait for workspace agents to be ready like other SSH tests do coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() // Use real clock for integration test deps, err := toolsdk.NewDeps(client) require.NoError(t, err) args := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `echo "123" && sleep 60 && echo "456"`, // This command would take 60+ seconds TimeoutMs: 2000, // 2 seconds timeout - should timeout after first echo } result, err := testTool(t, toolsdk.WorkspaceBash, deps, args) // Should not error (timeout is handled gracefully) require.NoError(t, err) t.Logf("Test results: exitCode=%d, output=%q, error=%v", result.ExitCode, result.Output, err) // Should have a non-zero exit code (timeout or error) require.NotEqual(t, 0, result.ExitCode, "Expected non-zero exit code for timeout") t.Logf("result.Output: %s", result.Output) // Should contain the first echo output require.Contains(t, result.Output, "123") // Should NOT contain the second echo (it never executed due to timeout) require.NotContains(t, result.Output, "456", "Should not contain output after sleep") }) t.Run("NormalCommandExecution", func(t *testing.T) { t.Parallel() // Test that normal commands still work with timeout functionality present client, workspace, agentToken := setupWorkspaceForAgent(t) // Start the agent and wait for it to be fully ready _ = agenttest.New(t, client.URL, agentToken) // Wait for workspace agents to be ready coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() deps, err := toolsdk.NewDeps(client) require.NoError(t, err) args := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `echo "normal command"`, // Quick command that should complete normally TimeoutMs: 5000, // 5 second timeout - plenty of time } // Use testTool to register the tool as tested and satisfy coverage validation result, err := testTool(t, toolsdk.WorkspaceBash, deps, args) // Should not error require.NoError(t, err) t.Logf("result.Output: %s", result.Output) // Should have exit code 0 (success) require.Equal(t, 0, result.ExitCode) // Should contain the expected output require.Equal(t, "normal command", result.Output) // Should NOT contain timeout message require.NotContains(t, result.Output, "Command canceled due to timeout") }) } func TestWorkspaceBashBackgroundIntegration(t *testing.T) { t.Parallel() t.Run("BackgroundCommandCapturesOutput", func(t *testing.T) { t.Parallel() client, workspace, agentToken := setupWorkspaceForAgent(t) // Start the agent and wait for it to be fully ready _ = agenttest.New(t, client.URL, agentToken) // Wait for workspace agents to be ready coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() deps, err := toolsdk.NewDeps(client) require.NoError(t, err) args := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `echo "started" && sleep 60 && echo "completed"`, // Command that would take 60+ seconds Background: true, // Run in background TimeoutMs: 2000, // 2 second timeout } result, err := testTool(t, toolsdk.WorkspaceBash, deps, args) // Should not error require.NoError(t, err) t.Logf("Background result: exitCode=%d, output=%q", result.ExitCode, result.Output) // Should have exit code 124 (timeout) since command times out require.Equal(t, 124, result.ExitCode) // Should capture output up to timeout point require.Contains(t, result.Output, "started", "Should contain output captured before timeout") // Should NOT contain the second echo (it never executed due to timeout) require.NotContains(t, result.Output, "completed", "Should not contain output after timeout") // Should contain background continuation message require.Contains(t, result.Output, "Command continues running in background") }) t.Run("BackgroundVsNormalExecution", func(t *testing.T) { t.Parallel() client, workspace, agentToken := setupWorkspaceForAgent(t) // Start the agent and wait for it to be fully ready _ = agenttest.New(t, client.URL, agentToken) // Wait for workspace agents to be ready coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() deps, err := toolsdk.NewDeps(client) require.NoError(t, err) // First run the same command in normal mode normalArgs := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `echo "hello world"`, Background: false, } normalResult, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, normalArgs) require.NoError(t, err) // Normal mode should return the actual output require.Equal(t, 0, normalResult.ExitCode) require.Equal(t, "hello world", normalResult.Output) // Now run the same command in background mode backgroundArgs := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `echo "hello world"`, Background: true, } backgroundResult, err := testTool(t, toolsdk.WorkspaceBash, deps, backgroundArgs) require.NoError(t, err) t.Logf("Normal result: %q", normalResult.Output) t.Logf("Background result: %q", backgroundResult.Output) // Background mode should also return the actual output since command completes quickly require.Equal(t, 0, backgroundResult.ExitCode) require.Equal(t, "hello world", backgroundResult.Output) }) t.Run("BackgroundCommandContinuesAfterTimeout", func(t *testing.T) { t.Parallel() client, workspace, agentToken := setupWorkspaceForAgent(t) // Start the agent and wait for it to be fully ready _ = agenttest.New(t, client.URL, agentToken) // Wait for workspace agents to be ready coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() deps, err := toolsdk.NewDeps(client) require.NoError(t, err) args := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `echo "started" && sleep 4 && echo "done" > /tmp/bg-test-done`, // Command that will timeout but continue TimeoutMs: 2000, // 2000ms timeout (shorter than command duration) Background: true, // Run in background } result, err := testTool(t, toolsdk.WorkspaceBash, deps, args) // Should not error but should timeout require.NoError(t, err) t.Logf("Background with timeout result: exitCode=%d, output=%q", result.ExitCode, result.Output) // Should have timeout exit code require.Equal(t, 124, result.ExitCode) // Should capture output before timeout require.Contains(t, result.Output, "started", "Should contain output captured before timeout") // Should contain background continuation message require.Contains(t, result.Output, "Command continues running in background") // Wait for the background command to complete (even though SSH session timed out) require.Eventually(t, func() bool { checkArgs := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, Command: `cat /tmp/bg-test-done 2>/dev/null || echo "not found"`, } checkResult, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, checkArgs) return err == nil && checkResult.Output == "done" }, testutil.WaitMedium, testutil.IntervalMedium, "Background command should continue running and complete after timeout") }) }