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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user