Skip to content

feat(codersdk/toolsdk): add MCP workspace bash background parameter #19034

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: thomask33/feat_add_timeout_support_to_workspace_bash_tool
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions codersdk/toolsdk/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package toolsdk
import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"errors"
"fmt"
"io"
Expand All @@ -18,19 +20,24 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/cryptorand"
)

type WorkspaceBashArgs struct {
Workspace string `json:"workspace"`
Command string `json:"command"`
TimeoutMs int `json:"timeout_ms,omitempty"`
Workspace string `json:"workspace"`
Command string `json:"command"`
TimeoutMs int `json:"timeout_ms,omitempty"`
Background bool `json:"background,omitempty"`
}

type WorkspaceBashResult struct {
Output string `json:"output"`
ExitCode int `json:"exit_code"`
}

//go:embed resources/background.sh
var backgroundScript string

var WorkspaceBash = Tool[WorkspaceBashArgs, WorkspaceBashResult]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceBash,
Expand All @@ -53,6 +60,7 @@ If the command times out, all output captured up to that point is returned with
Examples:
- workspace: "my-workspace", command: "ls -la"
- workspace: "john/dev-env", command: "git status", timeout_ms: 30000
- workspace: "my-workspace", command: "npm run dev", background: true
- workspace: "my-workspace.main", command: "docker ps"`,
Schema: aisdk.Schema{
Properties: map[string]any{
Expand All @@ -70,6 +78,10 @@ Examples:
"default": 60000,
"minimum": 1,
},
"background": map[string]any{
"type": "boolean",
"description": "Whether to run the command in the background. The command will not be affected by the timeout.",
},
},
Required: []string{"workspace", "command"},
},
Expand Down Expand Up @@ -137,16 +149,34 @@ Examples:

// Set default timeout if not specified (60 seconds)
timeoutMs := args.TimeoutMs
defaultTimeoutMs := 60000
if timeoutMs <= 0 {
timeoutMs = 60000
timeoutMs = defaultTimeoutMs
}
command := args.Command
if args.Background {
// Background commands are not affected by the timeout
timeoutMs = defaultTimeoutMs
encodedCommand := base64.StdEncoding.EncodeToString([]byte(args.Command))
encodedScript := base64.StdEncoding.EncodeToString([]byte(backgroundScript))
commandID, err := cryptorand.StringCharset(cryptorand.Human, 8)
if err != nil {
return WorkspaceBashResult{}, xerrors.Errorf("failed to generate command ID: %w", err)
}
command = fmt.Sprintf(
"ARG_COMMAND=\"$(echo -n %s | base64 -d)\" ARG_COMMAND_ID=%s bash -c \"$(echo -n %s | base64 -d)\"",
encodedCommand,
commandID,
encodedScript,
)
}

// Create context with timeout
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
defer cancel()

// Execute command with timeout handling
output, err := executeCommandWithTimeout(ctx, session, args.Command)
output, err := executeCommandWithTimeout(ctx, session, command)
outputStr := strings.TrimSpace(string(output))

// Handle command execution results
Expand Down
143 changes: 143 additions & 0 deletions codersdk/toolsdk/bash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"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) {
Expand Down Expand Up @@ -338,3 +339,145 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) {
require.NotContains(t, result.Output, "Command canceled due to timeout")
})
}

func TestWorkspaceBashBackgroundIntegration(t *testing.T) {
t.Parallel()

t.Run("BackgroundCommandReturnsImmediately", 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 5 && echo "completed"`, // Command that would take 5+ seconds
Background: true, // Run in background
}

result, err := toolsdk.WorkspaceBash.Handler(t.Context(), 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 0 (background start successful)
require.Equal(t, 0, result.ExitCode)

// Should contain PID and log path info, not the actual command output
require.Contains(t, result.Output, "Command started with PID:")
require.Contains(t, result.Output, "Log path: /tmp/mcp-bg/")

// Should NOT contain the actual command output since it runs in background
// The command was `echo "started" && sleep 5 && echo "completed"`
// So we check that the quoted strings don't appear in the output
require.NotContains(t, result.Output, `"started"`, "Should not contain command output in background mode")
require.NotContains(t, result.Output, `"completed"`, "Should not contain command output in background mode")
})

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 := toolsdk.WorkspaceBash.Handler(t.Context(), deps, backgroundArgs)
require.NoError(t, err)

t.Logf("Normal result: %q", normalResult.Output)
t.Logf("Background result: %q", backgroundResult.Output)

// Background mode should return PID/log info, not the actual output
require.Equal(t, 0, backgroundResult.ExitCode)
require.Contains(t, backgroundResult.Output, "Command started with PID:")
require.Contains(t, backgroundResult.Output, "Log path: /tmp/mcp-bg/")
require.NotContains(t, backgroundResult.Output, "hello world")
})

t.Run("BackgroundIgnoresTimeout", 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: `sleep 1 && echo "done" > /tmp/done`, // Command that would normally timeout
TimeoutMs: 1, // 1 ms timeout (shorter than command duration)
Background: true, // But running in background should ignore timeout
}

result, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, args)

// Should not error and should not timeout
require.NoError(t, err)

t.Logf("Background with timeout result: exitCode=%d, output=%q", result.ExitCode, result.Output)

// Should have exit code 0 (background start successful)
require.Equal(t, 0, result.ExitCode)

// Should return PID/log info, indicating the background command started successfully
require.Contains(t, result.Output, "Command started with PID:")
require.Contains(t, result.Output, "Log path: /tmp/mcp-bg/")

// Should NOT contain timeout message since background mode ignores timeout
require.NotContains(t, result.Output, "Command canceled due to timeout")

// Wait for the background command to complete
require.Eventually(t, func() bool {
args := toolsdk.WorkspaceBashArgs{
Workspace: workspace.Name,
Command: `cat /tmp/done`,
}
result, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, args)
return err == nil && result.Output == "done"
}, testutil.WaitMedium, testutil.IntervalMedium)
})
}
23 changes: 23 additions & 0 deletions codersdk/toolsdk/resources/background.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/bash

# This script is used to run a command in the background.

set -o errexit
set -o pipefail

set -o nounset

COMMAND="$ARG_COMMAND"
COMMAND_ID="$ARG_COMMAND_ID"

set +o nounset

LOG_DIR="/tmp/mcp-bg"
LOG_PATH="$LOG_DIR/$COMMAND_ID.log"
mkdir -p "$LOG_DIR"

nohup bash -c "$COMMAND" >"$LOG_PATH" 2>&1 &
COMMAND_PID="$!"

echo "Command started with PID: $COMMAND_PID"
echo "Log path: $LOG_PATH"
Loading