From 42b9181d0d2b84a35c5316a101f6f0e08f7e4f7b Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 24 Jul 2025 13:00:33 +0000 Subject: [PATCH 1/3] feat: workspace bash background parameter --- codersdk/toolsdk/bash.go | 40 +++++++++++++++++++++--- codersdk/toolsdk/resources/background.sh | 23 ++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 codersdk/toolsdk/resources/background.sh diff --git a/codersdk/toolsdk/bash.go b/codersdk/toolsdk/bash.go index 5fb15843f1bf1..6fd4bcbaae0a5 100644 --- a/codersdk/toolsdk/bash.go +++ b/codersdk/toolsdk/bash.go @@ -3,6 +3,8 @@ package toolsdk import ( "bytes" "context" + _ "embed" + "encoding/base64" "errors" "fmt" "io" @@ -18,12 +20,14 @@ 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 { @@ -31,6 +35,9 @@ type WorkspaceBashResult struct { ExitCode int `json:"exit_code"` } +//go:embed resources/background.sh +var backgroundScript string + var WorkspaceBash = Tool[WorkspaceBashArgs, WorkspaceBashResult]{ Tool: aisdk.Tool{ Name: ToolNameWorkspaceBash, @@ -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{ @@ -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"}, }, @@ -137,8 +149,26 @@ 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 @@ -146,7 +176,7 @@ Examples: 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 diff --git a/codersdk/toolsdk/resources/background.sh b/codersdk/toolsdk/resources/background.sh new file mode 100644 index 0000000000000..fdb3900403377 --- /dev/null +++ b/codersdk/toolsdk/resources/background.sh @@ -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" From 3750aee7bd7dec6babcf1aaab63ef41c2f720595 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 24 Jul 2025 16:56:21 +0000 Subject: [PATCH 2/3] chore: add workspace bash background tests --- codersdk/toolsdk/bash_test.go | 143 ++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/codersdk/toolsdk/bash_test.go b/codersdk/toolsdk/bash_test.go index 53ac480039278..a13f2ab8a8290 100644 --- a/codersdk/toolsdk/bash_test.go +++ b/codersdk/toolsdk/bash_test.go @@ -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) { @@ -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) + }) +} From d920c64805d22a4ff0e138c25c564f90164a772b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 28 Jul 2025 15:14:33 +0200 Subject: [PATCH 3/3] refactor(toolsdk): simplify workspace bash background execution with nohup Change-Id: If67c30717158bdd84e9f733b56365af7c8d0b51a Signed-off-by: Thomas Kosiewski --- codersdk/toolsdk/bash.go | 69 ++++++++++---------- codersdk/toolsdk/bash_test.go | 83 +++++++++++------------- codersdk/toolsdk/resources/background.sh | 23 ------- codersdk/toolsdk/toolsdk_test.go | 24 +++---- 4 files changed, 85 insertions(+), 114 deletions(-) delete mode 100644 codersdk/toolsdk/resources/background.sh diff --git a/codersdk/toolsdk/bash.go b/codersdk/toolsdk/bash.go index 6fd4bcbaae0a5..037227337bfc9 100644 --- a/codersdk/toolsdk/bash.go +++ b/codersdk/toolsdk/bash.go @@ -3,8 +3,6 @@ package toolsdk import ( "bytes" "context" - _ "embed" - "encoding/base64" "errors" "fmt" "io" @@ -20,7 +18,6 @@ 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 { @@ -35,9 +32,6 @@ type WorkspaceBashResult struct { ExitCode int `json:"exit_code"` } -//go:embed resources/background.sh -var backgroundScript string - var WorkspaceBash = Tool[WorkspaceBashArgs, WorkspaceBashResult]{ Tool: aisdk.Tool{ Name: ToolNameWorkspaceBash, @@ -57,10 +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", command: "npm run dev", background: true, timeout_ms: 10000 - workspace: "my-workspace.main", command: "docker ps"`, Schema: aisdk.Schema{ Properties: map[string]any{ @@ -80,7 +77,7 @@ Examples: }, "background": map[string]any{ "type": "boolean", - "description": "Whether to run the command in the background. The command will not be affected by the timeout.", + "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"}, @@ -155,35 +152,29 @@ Examples: } 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, - ) + // 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 &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, 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, @@ -417,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) } } @@ -451,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 } diff --git a/codersdk/toolsdk/bash_test.go b/codersdk/toolsdk/bash_test.go index a13f2ab8a8290..0656b2d8786e6 100644 --- a/codersdk/toolsdk/bash_test.go +++ b/codersdk/toolsdk/bash_test.go @@ -175,8 +175,6 @@ func TestWorkspaceBashTimeout(t *testing.T) { // Test that the TimeoutMs field can be set and read correctly args := toolsdk.WorkspaceBashArgs{ - Workspace: "test-workspace", - Command: "echo test", TimeoutMs: 0, // Should default to 60000 in handler } @@ -193,8 +191,6 @@ func TestWorkspaceBashTimeout(t *testing.T) { // Test that negative values can be set and will be handled by the default logic args := toolsdk.WorkspaceBashArgs{ - Workspace: "test-workspace", - Command: "echo test", TimeoutMs: -100, } @@ -280,7 +276,7 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) { TimeoutMs: 2000, // 2 seconds timeout - should timeout after first echo } - result, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, args) + result, err := testTool(t, toolsdk.WorkspaceBash, deps, args) // Should not error (timeout is handled gracefully) require.NoError(t, err) @@ -314,7 +310,6 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) { deps, err := toolsdk.NewDeps(client) require.NoError(t, err) - ctx := context.Background() args := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, @@ -322,7 +317,8 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) { 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) @@ -343,7 +339,7 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) { func TestWorkspaceBashBackgroundIntegration(t *testing.T) { t.Parallel() - t.Run("BackgroundCommandReturnsImmediately", func(t *testing.T) { + t.Run("BackgroundCommandCapturesOutput", func(t *testing.T) { t.Parallel() client, workspace, agentToken := setupWorkspaceForAgent(t) @@ -359,29 +355,29 @@ func TestWorkspaceBashBackgroundIntegration(t *testing.T) { args := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, - Command: `echo "started" && sleep 5 && echo "completed"`, // Command that would take 5+ seconds - Background: true, // Run in background + 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) + result, err := testTool(t, toolsdk.WorkspaceBash, 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 have exit code 124 (timeout) since command times out + require.Equal(t, 124, 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 capture output up to timeout point + require.Contains(t, result.Output, "started", "Should contain output captured before timeout") - // 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") + // 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) { @@ -419,20 +415,18 @@ func TestWorkspaceBashBackgroundIntegration(t *testing.T) { Background: true, } - backgroundResult, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, backgroundArgs) + backgroundResult, err := testTool(t, toolsdk.WorkspaceBash, 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 + // Background mode should also return the actual output since command completes quickly 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") + require.Equal(t, "hello world", backgroundResult.Output) }) - t.Run("BackgroundIgnoresTimeout", func(t *testing.T) { + t.Run("BackgroundCommandContinuesAfterTimeout", func(t *testing.T) { t.Parallel() client, workspace, agentToken := setupWorkspaceForAgent(t) @@ -448,36 +442,35 @@ func TestWorkspaceBashBackgroundIntegration(t *testing.T) { 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 + Command: `echo "started" && sleep 4 && echo "done" > /tmp/bg-test-done`, // Command that will timeout but continue + TimeoutMs: 2000, // 2000ms timeout (shorter than command duration) + Background: true, // Run in background } - result, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, args) + result, err := testTool(t, toolsdk.WorkspaceBash, deps, args) - // Should not error and should not timeout + // 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 exit code 0 (background start successful) - require.Equal(t, 0, result.ExitCode) + // Should have timeout exit code + require.Equal(t, 124, 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 capture output before timeout + require.Contains(t, result.Output, "started", "Should contain output captured before timeout") - // Should NOT contain timeout message since background mode ignores timeout - require.NotContains(t, result.Output, "Command canceled due to timeout") + // Should contain background continuation message + require.Contains(t, result.Output, "Command continues running in background") - // Wait for the background command to complete + // Wait for the background command to complete (even though SSH session timed out) require.Eventually(t, func() bool { - args := toolsdk.WorkspaceBashArgs{ + checkArgs := toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, - Command: `cat /tmp/done`, + Command: `cat /tmp/bg-test-done 2>/dev/null || echo "not found"`, } - result, err := toolsdk.WorkspaceBash.Handler(t.Context(), deps, args) - return err == nil && result.Output == "done" - }, testutil.WaitMedium, testutil.IntervalMedium) + 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") }) } diff --git a/codersdk/toolsdk/resources/background.sh b/codersdk/toolsdk/resources/background.sh deleted file mode 100644 index fdb3900403377..0000000000000 --- a/codersdk/toolsdk/resources/background.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/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" diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index c201190bd3456..13e475c80609a 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -456,7 +456,7 @@ var testedTools sync.Map // This is to mimic how we expect external callers to use the tool. func testTool[Arg, Ret any](t *testing.T, tool toolsdk.Tool[Arg, Ret], tb toolsdk.Deps, args Arg) (Ret, error) { t.Helper() - defer func() { testedTools.Store(tool.Tool.Name, true) }() + defer func() { testedTools.Store(tool.Name, true) }() toolArgs, err := json.Marshal(args) require.NoError(t, err, "failed to marshal args") result, err := tool.Generic().Handler(t.Context(), tb, toolArgs) @@ -625,23 +625,23 @@ func TestToolSchemaFields(t *testing.T) { // Test that all tools have the required Schema fields (Properties and Required) for _, tool := range toolsdk.All { - t.Run(tool.Tool.Name, func(t *testing.T) { + t.Run(tool.Name, func(t *testing.T) { t.Parallel() // Check that Properties is not nil - require.NotNil(t, tool.Tool.Schema.Properties, - "Tool %q missing Schema.Properties", tool.Tool.Name) + require.NotNil(t, tool.Schema.Properties, + "Tool %q missing Schema.Properties", tool.Name) // Check that Required is not nil - require.NotNil(t, tool.Tool.Schema.Required, - "Tool %q missing Schema.Required", tool.Tool.Name) + require.NotNil(t, tool.Schema.Required, + "Tool %q missing Schema.Required", tool.Name) // Ensure Properties has entries for all required fields - for _, requiredField := range tool.Tool.Schema.Required { - _, exists := tool.Tool.Schema.Properties[requiredField] + for _, requiredField := range tool.Schema.Required { + _, exists := tool.Schema.Properties[requiredField] require.True(t, exists, "Tool %q requires field %q but it is not defined in Properties", - tool.Tool.Name, requiredField) + tool.Name, requiredField) } }) } @@ -652,7 +652,7 @@ func TestToolSchemaFields(t *testing.T) { func TestMain(m *testing.M) { // Initialize testedTools for _, tool := range toolsdk.All { - testedTools.Store(tool.Tool.Name, false) + testedTools.Store(tool.Name, false) } code := m.Run() @@ -660,8 +660,8 @@ func TestMain(m *testing.M) { // Ensure all tools have been tested var untested []string for _, tool := range toolsdk.All { - if tested, ok := testedTools.Load(tool.Tool.Name); !ok || !tested.(bool) { - untested = append(untested, tool.Tool.Name) + if tested, ok := testedTools.Load(tool.Name); !ok || !tested.(bool) { + untested = append(untested, tool.Name) } }