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:
Thomas Kosiewski
2025-07-21 21:24:00 +02:00
committed by GitHub
parent 75c124013f
commit 326c02459f
4 changed files with 533 additions and 2 deletions
+75 -2
View File
@@ -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