mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add workspace SSH execution tool for AI SDK (#18924)
# Add SSH Command Execution Tool for Coder Workspaces This PR adds a new AI tool `coder_workspace_ssh_exec` that allows executing commands in Coder workspaces via SSH. The tool provides functionality similar to the `coder ssh <workspace> <command>` CLI command. Key features: - Executes commands in workspaces via SSH and returns the output and exit code - Automatically starts workspaces if they're stopped - Waits for the agent to be ready before executing commands - Trims leading and trailing whitespace from command output - Supports various workspace identifier formats: - `workspace` (uses current user) - `owner/workspace` - `owner--workspace` - `workspace.agent` (specific agent) - `owner/workspace.agent` The implementation includes: - A new tool definition with schema and handler - Helper functions for workspace and agent discovery - Workspace name normalization to handle different input formats - Comprehensive test coverage including integration tests This tool enables AI assistants to execute commands in user workspaces, making it possible to automate tasks and provide more interactive assistance. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced the ability to execute bash commands inside a Coder workspace via SSH, supporting multiple workspace identification formats. * **Tests** * Added comprehensive unit and integration tests for executing bash commands in workspaces, including input validation, output handling, and error scenarios. * **Chores** * Registered the new bash execution tool in the global tools list. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,295 @@
|
|||||||
|
package toolsdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
"github.com/coder/aisdk-go"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/cli/cliui"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceBashArgs struct {
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceBashResult struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var WorkspaceBash = Tool[WorkspaceBashArgs, WorkspaceBashResult]{
|
||||||
|
Tool: aisdk.Tool{
|
||||||
|
Name: ToolNameWorkspaceBash,
|
||||||
|
Description: `Execute a bash command in a Coder workspace.
|
||||||
|
|
||||||
|
This tool provides the same functionality as the 'coder ssh <workspace> <command>' CLI command.
|
||||||
|
It automatically starts the workspace if it's stopped and waits for the agent to be ready.
|
||||||
|
The output is trimmed of leading and trailing whitespace.
|
||||||
|
|
||||||
|
The workspace parameter supports various formats:
|
||||||
|
- workspace (uses current user)
|
||||||
|
- owner/workspace
|
||||||
|
- owner--workspace
|
||||||
|
- workspace.agent (specific agent)
|
||||||
|
- owner/workspace.agent
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- workspace: "my-workspace", command: "ls -la"
|
||||||
|
- workspace: "john/dev-env", command: "git status"
|
||||||
|
- workspace: "my-workspace.main", command: "docker ps"`,
|
||||||
|
Schema: aisdk.Schema{
|
||||||
|
Properties: map[string]any{
|
||||||
|
"workspace": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.",
|
||||||
|
},
|
||||||
|
"command": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The bash command to execute in the workspace.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"workspace", "command"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Handler: func(ctx context.Context, deps Deps, args WorkspaceBashArgs) (WorkspaceBashResult, error) {
|
||||||
|
if args.Workspace == "" {
|
||||||
|
return WorkspaceBashResult{}, xerrors.New("workspace name cannot be empty")
|
||||||
|
}
|
||||||
|
if args.Command == "" {
|
||||||
|
return WorkspaceBashResult{}, xerrors.New("command cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize workspace input to handle various formats
|
||||||
|
workspaceName := NormalizeWorkspaceInput(args.Workspace)
|
||||||
|
|
||||||
|
// Find workspace and agent
|
||||||
|
_, workspaceAgent, err := findWorkspaceAndAgent(ctx, deps.coderClient, workspaceName)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceBashResult{}, xerrors.Errorf("failed to find workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for agent to be ready
|
||||||
|
err = cliui.Agent(ctx, nil, workspaceAgent.ID, cliui.AgentOptions{
|
||||||
|
FetchInterval: 0,
|
||||||
|
Fetch: deps.coderClient.WorkspaceAgent,
|
||||||
|
FetchLogs: deps.coderClient.WorkspaceAgentLogsAfter,
|
||||||
|
Wait: true, // Always wait for startup scripts
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceBashResult{}, xerrors.Errorf("agent not ready: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create workspace SDK client for agent connection
|
||||||
|
wsClient := workspacesdk.New(deps.coderClient)
|
||||||
|
|
||||||
|
// Dial agent
|
||||||
|
conn, err := wsClient.DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
|
||||||
|
BlockEndpoints: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceBashResult{}, xerrors.Errorf("failed to dial agent: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Wait for connection to be reachable
|
||||||
|
if !conn.AwaitReachable(ctx) {
|
||||||
|
return WorkspaceBashResult{}, xerrors.New("agent connection not reachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SSH client
|
||||||
|
sshClient, err := conn.SSHClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceBashResult{}, xerrors.Errorf("failed to create SSH client: %w", err)
|
||||||
|
}
|
||||||
|
defer sshClient.Close()
|
||||||
|
|
||||||
|
// Create SSH session
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceBashResult{}, xerrors.Errorf("failed to create SSH session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// Execute command and capture output
|
||||||
|
output, err := session.CombinedOutput(args.Command)
|
||||||
|
outputStr := strings.TrimSpace(string(output))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's an SSH exit error to get the exit code
|
||||||
|
var exitErr *gossh.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return WorkspaceBashResult{
|
||||||
|
Output: outputStr,
|
||||||
|
ExitCode: exitErr.ExitStatus(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// For other errors, return exit code 1
|
||||||
|
return WorkspaceBashResult{
|
||||||
|
Output: outputStr,
|
||||||
|
ExitCode: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkspaceBashResult{
|
||||||
|
Output: outputStr,
|
||||||
|
ExitCode: 0,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWorkspaceAndAgent finds workspace and agent by name with auto-start support
|
||||||
|
func findWorkspaceAndAgent(ctx context.Context, client *codersdk.Client, workspaceName string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) {
|
||||||
|
// Parse workspace name to extract workspace and agent parts
|
||||||
|
parts := strings.Split(workspaceName, ".")
|
||||||
|
var agentName string
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
agentName = parts[1]
|
||||||
|
workspaceName = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workspace
|
||||||
|
workspace, err := namedWorkspace(ctx, client, workspaceName)
|
||||||
|
if err != nil {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start workspace if needed
|
||||||
|
if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart {
|
||||||
|
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionDelete {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is deleted", workspace.Name)
|
||||||
|
}
|
||||||
|
if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobFailed {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is in failed state", workspace.Name)
|
||||||
|
}
|
||||||
|
if workspace.LatestBuild.Status != codersdk.WorkspaceStatusStopped {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace must be started; was unable to autostart as the last build job is %q, expected %q",
|
||||||
|
workspace.LatestBuild.Status, codersdk.WorkspaceStatusStopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start workspace
|
||||||
|
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||||
|
Transition: codersdk.WorkspaceTransitionStart,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("failed to start workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for build to complete
|
||||||
|
if build.Job.CompletedAt == nil {
|
||||||
|
err := cliui.WorkspaceBuild(ctx, io.Discard, client, build.ID)
|
||||||
|
if err != nil {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("failed to wait for build completion: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh workspace after build completes
|
||||||
|
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||||
|
if err != nil {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find agent
|
||||||
|
workspaceAgent, err := getWorkspaceAgent(workspace, agentName)
|
||||||
|
if err != nil {
|
||||||
|
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace, workspaceAgent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWorkspaceAgent finds the specified agent in the workspace
|
||||||
|
func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (codersdk.WorkspaceAgent, error) {
|
||||||
|
resources := workspace.LatestBuild.Resources
|
||||||
|
|
||||||
|
var agents []codersdk.WorkspaceAgent
|
||||||
|
var availableNames []string
|
||||||
|
|
||||||
|
for _, resource := range resources {
|
||||||
|
for _, agent := range resource.Agents {
|
||||||
|
availableNames = append(availableNames, agent.Name)
|
||||||
|
agents = append(agents, agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(agents) == 0 {
|
||||||
|
return codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentName != "" {
|
||||||
|
for _, agent := range agents {
|
||||||
|
if agent.Name == agentName || agent.ID.String() == agentName {
|
||||||
|
return agent, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q, available agents: %v", agentName, availableNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(agents) == 1 {
|
||||||
|
return agents[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return codersdk.WorkspaceAgent{}, xerrors.Errorf("multiple agents found, please specify the agent name, available agents: %v", availableNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// namedWorkspace gets a workspace by owner/name or just name
|
||||||
|
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
|
||||||
|
// Parse owner and workspace name
|
||||||
|
parts := strings.SplitN(identifier, "/", 2)
|
||||||
|
var owner, workspaceName string
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
owner = parts[0]
|
||||||
|
workspaceName = parts[1]
|
||||||
|
} else {
|
||||||
|
owner = "me"
|
||||||
|
workspaceName = identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle -- separator format (convert to / format)
|
||||||
|
if strings.Contains(identifier, "--") && !strings.Contains(identifier, "/") {
|
||||||
|
dashParts := strings.SplitN(identifier, "--", 2)
|
||||||
|
if len(dashParts) == 2 {
|
||||||
|
owner = dashParts[0]
|
||||||
|
workspaceName = dashParts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.WorkspaceByOwnerAndName(ctx, owner, workspaceName, codersdk.WorkspaceOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeWorkspaceInput converts workspace name input to standard format.
|
||||||
|
// Handles the following input formats:
|
||||||
|
// - workspace → workspace
|
||||||
|
// - workspace.agent → workspace.agent
|
||||||
|
// - owner/workspace → owner/workspace
|
||||||
|
// - owner--workspace → owner/workspace
|
||||||
|
// - owner/workspace.agent → owner/workspace.agent
|
||||||
|
// - owner--workspace.agent → owner/workspace.agent
|
||||||
|
// - agent.workspace.owner → owner/workspace.agent (Coder Connect format)
|
||||||
|
func NormalizeWorkspaceInput(input string) string {
|
||||||
|
// Handle the special Coder Connect format: agent.workspace.owner
|
||||||
|
// This format uses only dots and has exactly 3 parts
|
||||||
|
if strings.Count(input, ".") == 2 && !strings.Contains(input, "/") && !strings.Contains(input, "--") {
|
||||||
|
parts := strings.Split(input, ".")
|
||||||
|
if len(parts) == 3 {
|
||||||
|
// Convert agent.workspace.owner → owner/workspace.agent
|
||||||
|
return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert -- separator to / separator for consistency
|
||||||
|
normalized := strings.ReplaceAll(input, "--", "/")
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package toolsdk_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
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{} // Empty deps will cause client access to fail
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ const (
|
|||||||
ToolNameUploadTarFile = "coder_upload_tar_file"
|
ToolNameUploadTarFile = "coder_upload_tar_file"
|
||||||
ToolNameCreateTemplate = "coder_create_template"
|
ToolNameCreateTemplate = "coder_create_template"
|
||||||
ToolNameDeleteTemplate = "coder_delete_template"
|
ToolNameDeleteTemplate = "coder_delete_template"
|
||||||
|
ToolNameWorkspaceBash = "coder_workspace_bash"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
|
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
|
||||||
@@ -183,6 +184,7 @@ var All = []GenericTool{
|
|||||||
ReportTask.Generic(),
|
ReportTask.Generic(),
|
||||||
UploadTarFile.Generic(),
|
UploadTarFile.Generic(),
|
||||||
UpdateTemplateActiveVersion.Generic(),
|
UpdateTemplateActiveVersion.Generic(),
|
||||||
|
WorkspaceBash.Generic(),
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportTaskArgs struct {
|
type ReportTaskArgs struct {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/aisdk-go"
|
"github.com/coder/aisdk-go"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/agent/agenttest"
|
||||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||||
@@ -27,11 +28,32 @@ import (
|
|||||||
"github.com/coder/coder/v2/testutil"
|
"github.com/coder/coder/v2/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// setupWorkspaceForAgent creates a workspace setup exactly like main SSH tests
|
||||||
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
||||||
|
func setupWorkspaceForAgent(t *testing.T) (*codersdk.Client, database.WorkspaceTable, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||||
|
client.SetLogger(testutil.Logger(t).Named("client"))
|
||||||
|
first := coderdtest.CreateFirstUser(t, client)
|
||||||
|
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||||
|
r.Username = "myuser"
|
||||||
|
})
|
||||||
|
// nolint:gocritic // This is in a test package and does not end up in the build
|
||||||
|
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||||
|
Name: "myworkspace",
|
||||||
|
OrganizationID: first.OrganizationID,
|
||||||
|
OwnerID: user.ID,
|
||||||
|
}).WithAgent().Do()
|
||||||
|
|
||||||
|
return userClient, r.Workspace, r.AgentToken
|
||||||
|
}
|
||||||
|
|
||||||
// These tests are dependent on the state of the coder server.
|
// These tests are dependent on the state of the coder server.
|
||||||
// Running them in parallel is prone to racy behavior.
|
// Running them in parallel is prone to racy behavior.
|
||||||
// nolint:tparallel,paralleltest
|
// nolint:tparallel,paralleltest
|
||||||
func TestTools(t *testing.T) {
|
func TestTools(t *testing.T) {
|
||||||
// Given: a running coderd instance
|
// Given: a running coderd instance using SSH test setup pattern
|
||||||
setupCtx := testutil.Context(t, testutil.WaitShort)
|
setupCtx := testutil.Context(t, testutil.WaitShort)
|
||||||
client, store := coderdtest.NewWithDatabase(t, nil)
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||||
owner := coderdtest.CreateFirstUser(t, client)
|
owner := coderdtest.CreateFirstUser(t, client)
|
||||||
@@ -373,6 +395,57 @@ func TestTools(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
require.NotEmpty(t, res.ID, "expected a workspace ID")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("WorkspaceSSHExec", func(t *testing.T) {
|
||||||
|
// Setup workspace exactly like main SSH tests
|
||||||
|
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||||
|
|
||||||
|
// Start agent and wait for it to be ready (following main SSH test pattern)
|
||||||
|
_ = agenttest.New(t, client.URL, agentToken)
|
||||||
|
|
||||||
|
// Wait for workspace agents to be ready like main SSH tests do
|
||||||
|
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
||||||
|
|
||||||
|
// Create tool dependencies using client
|
||||||
|
tb, err := toolsdk.NewDeps(client)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test basic command execution
|
||||||
|
result, err := testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
||||||
|
Workspace: workspace.Name,
|
||||||
|
Command: "echo 'hello world'",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, result.ExitCode)
|
||||||
|
require.Equal(t, "hello world", result.Output)
|
||||||
|
|
||||||
|
// Test output trimming
|
||||||
|
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
||||||
|
Workspace: workspace.Name,
|
||||||
|
Command: "echo -e '\\n test with whitespace \\n'",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, result.ExitCode)
|
||||||
|
require.Equal(t, "test with whitespace", result.Output) // Should be trimmed
|
||||||
|
|
||||||
|
// Test non-zero exit code
|
||||||
|
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
||||||
|
Workspace: workspace.Name,
|
||||||
|
Command: "exit 42",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 42, result.ExitCode)
|
||||||
|
require.Empty(t, result.Output)
|
||||||
|
|
||||||
|
// Test with workspace owner format - using the myuser from setup
|
||||||
|
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
|
||||||
|
Workspace: "myuser/" + workspace.Name,
|
||||||
|
Command: "echo 'owner format works'",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, result.ExitCode)
|
||||||
|
require.Equal(t, "owner format works", result.Output)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestedTools keeps track of which tools have been tested.
|
// TestedTools keeps track of which tools have been tested.
|
||||||
@@ -386,7 +459,7 @@ func testTool[Arg, Ret any](t *testing.T, tool toolsdk.Tool[Arg, Ret], tb toolsd
|
|||||||
defer func() { testedTools.Store(tool.Tool.Name, true) }()
|
defer func() { testedTools.Store(tool.Tool.Name, true) }()
|
||||||
toolArgs, err := json.Marshal(args)
|
toolArgs, err := json.Marshal(args)
|
||||||
require.NoError(t, err, "failed to marshal args")
|
require.NoError(t, err, "failed to marshal args")
|
||||||
result, err := tool.Generic().Handler(context.Background(), tb, toolArgs)
|
result, err := tool.Generic().Handler(t.Context(), tb, toolArgs)
|
||||||
var ret Ret
|
var ret Ret
|
||||||
require.NoError(t, json.Unmarshal(result, &ret), "failed to unmarshal result %q", string(result))
|
require.NoError(t, json.Unmarshal(result, &ret), "failed to unmarshal result %q", string(result))
|
||||||
return ret, err
|
return ret, err
|
||||||
|
|||||||
Reference in New Issue
Block a user