Content-Length: 20866 | pFad | http://github.com/coder/coder/pull/18924.patch
thub.com
From 3970946e66cf74dc161bfe4a8380fdc40631d103 Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski
Date: Sun, 20 Jul 2025 16:18:06 +0200
Subject: [PATCH] feat(toolsdk): add SSH exec tool
Change-Id: I61f694a89e33c60ab6e5a68b6773755bff1840a4
Signed-off-by: Thomas Kosiewski
---
codersdk/toolsdk/bash.go | 295 +++++++++++++++++++++++++++++++
codersdk/toolsdk/bash_test.go | 161 +++++++++++++++++
codersdk/toolsdk/toolsdk.go | 2 +
codersdk/toolsdk/toolsdk_test.go | 77 +++++++-
4 files changed, 533 insertions(+), 2 deletions(-)
create mode 100644 codersdk/toolsdk/bash.go
create mode 100644 codersdk/toolsdk/bash_test.go
diff --git a/codersdk/toolsdk/bash.go b/codersdk/toolsdk/bash.go
new file mode 100644
index 0000000000000..0df5f69aa71c9
--- /dev/null
+++ b/codersdk/toolsdk/bash.go
@@ -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 ' 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
+}
diff --git a/codersdk/toolsdk/bash_test.go b/codersdk/toolsdk/bash_test.go
new file mode 100644
index 0000000000000..474071fc45acb
--- /dev/null
+++ b/codersdk/toolsdk/bash_test.go
@@ -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")
+}
diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go
index 4055674f6d2d3..6ef310f510369 100644
--- a/codersdk/toolsdk/toolsdk.go
+++ b/codersdk/toolsdk/toolsdk.go
@@ -33,6 +33,7 @@ const (
ToolNameUploadTarFile = "coder_upload_tar_file"
ToolNameCreateTemplate = "coder_create_template"
ToolNameDeleteTemplate = "coder_delete_template"
+ ToolNameWorkspaceBash = "coder_workspace_bash"
)
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
@@ -183,6 +184,7 @@ var All = []GenericTool{
ReportTask.Generic(),
UploadTarFile.Generic(),
UpdateTemplateActiveVersion.Generic(),
+ WorkspaceBash.Generic(),
}
type ReportTaskArgs struct {
diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go
index 09b919a428a84..5e4a33ba67575 100644
--- a/codersdk/toolsdk/toolsdk_test.go
+++ b/codersdk/toolsdk/toolsdk_test.go
@@ -16,6 +16,7 @@ import (
"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/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
@@ -27,11 +28,32 @@ import (
"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.
// Running them in parallel is prone to racy behavior.
// nolint:tparallel,paralleltest
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)
client, store := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
@@ -373,6 +395,57 @@ func TestTools(t *testing.T) {
require.NoError(t, err)
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.
@@ -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) }()
toolArgs, err := json.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
require.NoError(t, json.Unmarshal(result, &ret), "failed to unmarshal result %q", string(result))
return ret, err
--- a PPN by Garber Painting Akron. With Image Size Reduction included!Fetched URL: http://github.com/coder/coder/pull/18924.patch
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy