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 3 commits into
base: main
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
63 changes: 47 additions & 16 deletions codersdk/toolsdk/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import (
)

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 {
Expand All @@ -50,9 +51,13 @@ The workspace parameter supports various formats:
The timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).
If the command times out, all output captured up to that point is returned with a cancellation message.

For background commands (background: true), output is captured until the timeout is reached, then the command
continues running in the background. The captured output is returned as the result.

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 +75,10 @@ Examples:
"default": 60000,
"minimum": 1,
},
"background": map[string]any{
"type": "boolean",
"description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.",
},
},
Required: []string{"workspace", "command"},
},
Expand Down Expand Up @@ -137,23 +146,35 @@ 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 {
// For background commands, use nohup directly to ensure they survive SSH session
// termination. This captures output normally but allows the process to continue
// running even after the SSH connection closes.
command = fmt.Sprintf("nohup %s </dev/null 2>&1", args.Command)
}

// Create context with timeout
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
defer cancel()
// Create context with command timeout (replace the broader MCP timeout)
commandCtx, commandCancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
defer commandCancel()

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

// Handle command execution results
if err != nil {
// Check if the command timed out
if errors.Is(context.Cause(ctx), context.DeadlineExceeded) {
outputStr += "\nCommand canceled due to timeout"
if errors.Is(context.Cause(commandCtx), context.DeadlineExceeded) {
if args.Background {
outputStr += "\nCommand continues running in background"
} else {
outputStr += "\nCommand canceled due to timeout"
}
return WorkspaceBashResult{
Output: outputStr,
ExitCode: 124,
Expand Down Expand Up @@ -387,21 +408,27 @@ func executeCommandWithTimeout(ctx context.Context, session *gossh.Session, comm
return safeWriter.Bytes(), err
case <-ctx.Done():
// Context was canceled (timeout or other cancellation)
// Close the session to stop the command
_ = session.Close()
// Close the session to stop the command, but handle errors gracefully
closeErr := session.Close()

// Give a brief moment to collect any remaining output
timer := time.NewTimer(50 * time.Millisecond)
// Give a brief moment to collect any remaining output and for goroutines to finish
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()

select {
case <-timer.C:
// Timer expired, return what we have
break
case err := <-done:
// Command finished during grace period
return safeWriter.Bytes(), err
if closeErr == nil {
return safeWriter.Bytes(), err
}
// If session close failed, prioritize the context error
break
}

// Return the collected output with the context error
return safeWriter.Bytes(), context.Cause(ctx)
}
}
Expand All @@ -421,5 +448,9 @@ func (sw *syncWriter) Write(p []byte) (n int, err error) {
func (sw *syncWriter) Bytes() []byte {
sw.mu.Lock()
defer sw.mu.Unlock()
return sw.w.Bytes()
// Return a copy to prevent race conditions with the underlying buffer
b := sw.w.Bytes()
result := make([]byte, len(b))
copy(result, b)
return result
}
144 changes: 142 additions & 2 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 @@ -313,15 +314,15 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) {

deps, err := toolsdk.NewDeps(client)
require.NoError(t, err)
ctx := context.Background()

args := toolsdk.WorkspaceBashArgs{
Workspace: workspace.Name,
Command: `echo "normal command"`, // Quick command that should complete normally
TimeoutMs: 5000, // 5 second timeout - plenty of time
}

result, err := toolsdk.WorkspaceBash.Handler(ctx, deps, args)
// Use testTool to register the tool as tested and satisfy coverage validation
result, err := testTool(t, toolsdk.WorkspaceBash, deps, args)

// Should not error
require.NoError(t, err)
Expand All @@ -338,3 +339,142 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) {
require.NotContains(t, result.Output, "Command canceled due to timeout")
})
}

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

t.Run("BackgroundCommandCapturesOutput", 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 60 && echo "completed"`, // Command that would take 60+ seconds
Background: true, // Run in background
TimeoutMs: 2000, // 2 second timeout
}

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 124 (timeout) since command times out
require.Equal(t, 124, result.ExitCode)

// Should capture output up to timeout point
require.Contains(t, result.Output, "started", "Should contain output captured before timeout")

// Should NOT contain the second echo (it never executed due to timeout)
require.NotContains(t, result.Output, "completed", "Should not contain output after timeout")

// Should contain background continuation message
require.Contains(t, result.Output, "Command continues running in background")
})

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 also return the actual output since command completes quickly
require.Equal(t, 0, backgroundResult.ExitCode)
require.Equal(t, "hello world", backgroundResult.Output)
})

t.Run("BackgroundCommandContinuesAfterTimeout", 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 10 && echo "done" > /tmp/bg-test-done`, // Command that will timeout but continue
TimeoutMs: 5000, // 5000ms timeout (shorter than command duration)
Background: true, // Run in background
}

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

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

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

// Should have timeout exit code
require.Equal(t, 124, result.ExitCode)

// Should capture output before timeout
require.Contains(t, result.Output, "started", "Should contain output captured before timeout")

// Should contain background continuation message
require.Contains(t, result.Output, "Command continues running in background")

// Wait for the background command to complete (even though SSH session timed out)
require.Eventually(t, func() bool {
checkArgs := toolsdk.WorkspaceBashArgs{
Workspace: workspace.Name,
Command: `cat /tmp/bg-test-done 2>/dev/null || echo "not found"`,
}
checkResult, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, checkArgs)
return err == nil && checkResult.Output == "done"
}, testutil.WaitMedium, testutil.IntervalMedium, "Background command should continue running and complete after timeout")
})
}
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy