diff --git a/.github/workflows/campaign-generator.lock.yml b/.github/workflows/campaign-generator.lock.yml index 1854e2bba3..68fd7ac764 100644 --- a/.github/workflows/campaign-generator.lock.yml +++ b/.github/workflows/campaign-generator.lock.yml @@ -180,7 +180,7 @@ jobs: which awf awf --version - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.7 + run: npm install -g --silent @anthropic-ai/claude-code@2.1.9 - name: Determine automatic lockdown mode for GitHub MCP server id: determine-automatic-lockdown env: @@ -192,7 +192,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.62 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p /opt/gh-aw/safeoutputs @@ -678,7 +678,7 @@ jobs: # Register API key as secret to mask it from logs echo "::add-mask::${MCP_GATEWAY_API_KEY}" export GH_AW_ENGINE="claude" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e DEBUG="*" -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/githubnext/gh-aw-mcpg:v0.0.60' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e DEBUG="*" -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/githubnext/gh-aw-mcpg:v0.0.62' cat << MCPCONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -732,7 +732,7 @@ jobs: engine_name: "Claude Code", model: process.env.GH_AW_MODEL_AGENT_CLAUDE || "", version: "", - agent_version: "2.1.7", + agent_version: "2.1.9", workflow_name: "Campaign Generator", experimental: true, supports_tools_allowlist: true, @@ -750,7 +750,7 @@ jobs: allowed_domains: [], firewall_enabled: true, awf_version: "v0.9.1", - awmg_version: "v0.0.60", + awmg_version: "v0.0.62", steps: { firewall: "squid" }, @@ -1133,14 +1133,14 @@ jobs: - name: Append context instructions to prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} run: | cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" @@ -1620,7 +1620,7 @@ jobs: node-version: '24' package-manager-cache: false - name: Install Claude Code CLI - run: npm install -g --silent @anthropic-ai/claude-code@2.1.7 + run: npm install -g --silent @anthropic-ai/claude-code@2.1.9 - name: Execute Claude Code CLI id: agentic_execution # Allowed tools (sorted): diff --git a/.github/workflows/workflow-skill-extractor.lock.yml b/.github/workflows/workflow-skill-extractor.lock.yml index 095c226b25..4a94255279 100644 --- a/.github/workflows/workflow-skill-extractor.lock.yml +++ b/.github/workflows/workflow-skill-extractor.lock.yml @@ -145,7 +145,7 @@ jobs: # Execute the installer with the specified version # Pass VERSION directly to sudo to ensure it's available to the installer script - sudo VERSION=0.0.382 bash /tmp/copilot-install.sh + sudo VERSION=0.0.384 bash /tmp/copilot-install.sh # Cleanup rm -f /tmp/copilot-install.sh @@ -169,7 +169,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.62 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p /opt/gh-aw/safeoutputs @@ -433,7 +433,7 @@ jobs: # Register API key as secret to mask it from logs echo "::add-mask::${MCP_GATEWAY_API_KEY}" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e DEBUG="*" -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/githubnext/gh-aw-mcpg:v0.0.60' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e DEBUG="*" -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/githubnext/gh-aw-mcpg:v0.0.62' mkdir -p /home/runner/.copilot cat << MCPCONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh @@ -490,7 +490,7 @@ jobs: engine_name: "GitHub Copilot CLI", model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", version: "", - agent_version: "0.0.382", + agent_version: "0.0.384", workflow_name: "Workflow Skill Extractor", experimental: false, supports_tools_allowlist: true, @@ -508,7 +508,7 @@ jobs: allowed_domains: [], firewall_enabled: true, awf_version: "v0.9.1", - awmg_version: "v0.0.60", + awmg_version: "v0.0.62", steps: { firewall: "squid" }, @@ -949,7 +949,6 @@ jobs: - name: Append context instructions to prompt env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} @@ -957,6 +956,7 @@ jobs: GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} run: | cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" @@ -1396,7 +1396,7 @@ jobs: # Execute the installer with the specified version # Pass VERSION directly to sudo to ensure it's available to the installer script - sudo VERSION=0.0.382 bash /tmp/copilot-install.sh + sudo VERSION=0.0.384 bash /tmp/copilot-install.sh # Cleanup rm -f /tmp/copilot-install.sh diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 8cb392ec30..682d07a0f5 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2330,6 +2330,11 @@ "description": "Optional version/tag for the container image (e.g., 'latest', 'v1.0.0')", "examples": ["latest", "v1.0.0"] }, + "entrypoint": { + "type": "string", + "description": "Optional custom entrypoint for the MCP gateway container. Overrides the container's default entrypoint.", + "examples": ["/bin/bash", "/custom/start.sh", "/usr/bin/env"] + }, "args": { "type": "array", "items": { @@ -2344,6 +2349,21 @@ }, "description": "Arguments to add after the container image (container entrypoint arguments)" }, + "mounts": { + "type": "array", + "description": "Volume mounts for the MCP gateway container. Each mount is specified using Docker mount syntax: 'source:destination:mode' where mode can be 'ro' (read-only) or 'rw' (read-write). Example: '/host/data:/container/data:ro'", + "items": { + "type": "string", + "pattern": "^[^:]+:[^:]+:(ro|rw)$", + "description": "Mount specification in format 'source:destination:mode'" + }, + "examples": [ + [ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw" + ] + ] + }, "env": { "type": "object", "patternProperties": { diff --git a/pkg/workflow/expression_validation.go b/pkg/workflow/expression_validation.go index 5a4d0a00ba..104427174b 100644 --- a/pkg/workflow/expression_validation.go +++ b/pkg/workflow/expression_validation.go @@ -227,7 +227,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption // Match pattern: something || something_else orPattern := regexp.MustCompile(`^(.+?)\s*\|\|\s*(.+)$`) orMatch := orPattern.FindStringSubmatch(expression) - if orMatch != nil && len(orMatch) > 2 { + if len(orMatch) > 2 { leftExpr := strings.TrimSpace(orMatch[1]) rightExpr := strings.TrimSpace(orMatch[2]) @@ -237,7 +237,8 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption if leftIsSafe { // Check if right side is a literal string (single, double, or backtick quotes) - isStringLiteral := regexp.MustCompile(`^(['"\x60]).*\1$`).MatchString(rightExpr) + // Note: Using (?:) for non-capturing group and checking each quote type separately + isStringLiteral := regexp.MustCompile(`^'[^']*'$|^"[^"]*"$|^` + "`[^`]*`$").MatchString(rightExpr) // Check if right side is a number literal isNumberLiteral := regexp.MustCompile(`^-?\d+(\.\d+)?$`).MatchString(rightExpr) // Check if right side is a boolean literal diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index c741efaf4e..df64270329 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -350,6 +350,13 @@ func (c *Compiler) extractMCPGatewayConfig(mcpVal any) *MCPGatewayRuntimeConfig } } + // Extract entrypoint (optional container entrypoint override) + if entrypointVal, hasEntrypoint := mcpObj["entrypoint"]; hasEntrypoint { + if entrypointStr, ok := entrypointVal.(string); ok { + mcpConfig.Entrypoint = entrypointStr + } + } + // Extract port if portVal, hasPort := mcpObj["port"]; hasPort { switch v := portVal.(type) { @@ -414,6 +421,17 @@ func (c *Compiler) extractMCPGatewayConfig(mcpVal any) *MCPGatewayRuntimeConfig } } + // Extract mounts (volume mounts for container) + if mountsVal, hasMounts := mcpObj["mounts"]; hasMounts { + if mountsSlice, ok := mountsVal.([]any); ok { + for _, mount := range mountsSlice { + if mountStr, ok := mount.(string); ok { + mcpConfig.Mounts = append(mcpConfig.Mounts, mountStr) + } + } + } + } + return mcpConfig } diff --git a/pkg/workflow/mcp_gateway_entrypoint_mounts_e2e_test.go b/pkg/workflow/mcp_gateway_entrypoint_mounts_e2e_test.go new file mode 100644 index 0000000000..d587f9c2a0 --- /dev/null +++ b/pkg/workflow/mcp_gateway_entrypoint_mounts_e2e_test.go @@ -0,0 +1,314 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/stringutil" + "github.com/githubnext/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMCPGatewayEntrypointE2E tests end-to-end compilation with entrypoint configuration +func TestMCPGatewayEntrypointE2E(t *testing.T) { + markdown := `--- +on: workflow_dispatch +engine: copilot +sandbox: + mcp: + container: ghcr.io/githubnext/gh-aw-mcpg + entrypoint: /custom/start.sh + entrypointArgs: + - --verbose + - --port + - "8080" +--- + +# Test Workflow + +Test that entrypoint is properly extracted and included in the compiled workflow. +` + + // Create temporary directory and file + tmpDir := testutil.TempDir(t, "entrypoint-test") + testFile := filepath.Join(tmpDir, "test-entrypoint.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + // Read the generated lock file + lockFile := stringutil.MarkdownToLockFile(testFile) + result, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + require.NotEmpty(t, result, "Compiled YAML should not be empty") + + // Convert to string for easier searching + yamlStr := string(result) + + // Verify the entrypoint flag is in the docker command + assert.Contains(t, yamlStr, "--entrypoint", "Compiled YAML should contain --entrypoint flag") + assert.Contains(t, yamlStr, "/custom/start.sh", "Compiled YAML should contain entrypoint value") + + // Verify entrypoint args are present + assert.Contains(t, yamlStr, "--verbose", "Compiled YAML should contain entrypoint arg --verbose") + assert.Contains(t, yamlStr, "--port", "Compiled YAML should contain entrypoint arg --port") + assert.Contains(t, yamlStr, "8080", "Compiled YAML should contain entrypoint arg value 8080") + + // Verify all elements are present (ordering can vary due to multiple mentions of container) + assert.Positive(t, strings.Index(yamlStr, "--entrypoint"), "Entrypoint flag should be in YAML") + assert.Positive(t, strings.Index(yamlStr, "/custom/start.sh"), "Entrypoint value should be in YAML") + assert.Positive(t, strings.Index(yamlStr, "ghcr.io/githubnext/gh-aw-mcpg"), "Container should be in YAML") +} + +// TestMCPGatewayMountsE2E tests end-to-end compilation with mounts configuration +func TestMCPGatewayMountsE2E(t *testing.T) { + markdown := `--- +on: workflow_dispatch +engine: copilot +sandbox: + mcp: + container: ghcr.io/githubnext/gh-aw-mcpg + mounts: + - /host/data:/container/data:ro + - /host/config:/container/config:rw +--- + +# Test Workflow + +Test that mounts are properly extracted and included in the compiled workflow. +` + + // Create temporary directory and file + tmpDir := testutil.TempDir(t, "mounts-test") + testFile := filepath.Join(tmpDir, "test-mounts.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + // Read the generated lock file + lockFile := stringutil.MarkdownToLockFile(testFile) + result, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + require.NotEmpty(t, result, "Compiled YAML should not be empty") + + // Convert to string for easier searching + yamlStr := string(result) + + // Verify the volume mount flags are in the docker command + assert.Contains(t, yamlStr, "-v /host/data:/container/data:ro", "Compiled YAML should contain first mount") + assert.Contains(t, yamlStr, "-v /host/config:/container/config:rw", "Compiled YAML should contain second mount") + + // Verify all elements are present (ordering can vary due to multiple mentions of container) + assert.Positive(t, strings.Index(yamlStr, "-v /host/data:/container/data:ro"), "First mount should be in YAML") + assert.Positive(t, strings.Index(yamlStr, "ghcr.io/githubnext/gh-aw-mcpg"), "Container should be in YAML") +} + +// TestMCPGatewayEntrypointAndMountsE2E tests end-to-end compilation with both entrypoint and mounts +func TestMCPGatewayEntrypointAndMountsE2E(t *testing.T) { + markdown := `--- +on: workflow_dispatch +engine: copilot +sandbox: + mcp: + container: ghcr.io/githubnext/gh-aw-mcpg + entrypoint: /bin/bash + entrypointArgs: + - -c + - "exec /app/start.sh" + mounts: + - /var/data:/app/data:rw + - /etc/secrets:/app/secrets:ro +--- + +# Test Workflow + +Test that both entrypoint and mounts are properly extracted and included in the compiled workflow. +` + + // Create temporary directory and file + tmpDir := testutil.TempDir(t, "combined-test") + testFile := filepath.Join(tmpDir, "test-combined.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + // Read the generated lock file + lockFile := stringutil.MarkdownToLockFile(testFile) + result, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + require.NotEmpty(t, result, "Compiled YAML should not be empty") + + // Convert to string for easier searching + yamlStr := string(result) + + // Verify entrypoint is present + assert.Contains(t, yamlStr, "--entrypoint", "Compiled YAML should contain --entrypoint flag") + assert.Contains(t, yamlStr, "/bin/bash", "Compiled YAML should contain entrypoint value") + + // Verify entrypoint args are present + assert.Contains(t, yamlStr, "-c", "Compiled YAML should contain entrypoint arg -c") + assert.Contains(t, yamlStr, "exec /app/start.sh", "Compiled YAML should contain entrypoint command") + + // Verify mounts are present + assert.Contains(t, yamlStr, "-v /var/data:/app/data:rw", "Compiled YAML should contain first mount") + assert.Contains(t, yamlStr, "-v /etc/secrets:/app/secrets:ro", "Compiled YAML should contain second mount") + + // Verify that entrypoint and container appear in a reasonable order + // Note: We're less strict on ordering since the container name may appear multiple times + // The important thing is that all elements are present + assert.Positive(t, strings.Index(yamlStr, "-v /var/data:/app/data:rw"), "Mount should be in the YAML") + assert.Positive(t, strings.Index(yamlStr, "--entrypoint"), "Entrypoint should be in the YAML") + assert.Positive(t, strings.Index(yamlStr, "ghcr.io/githubnext/gh-aw-mcpg"), "Container should be in the YAML") +} + +// TestMCPGatewayWithoutEntrypointOrMountsE2E tests that workflows without these fields compile correctly +func TestMCPGatewayWithoutEntrypointOrMountsE2E(t *testing.T) { + markdown := `--- +on: workflow_dispatch +engine: copilot +--- + +# Test Workflow + +Test that workflows without entrypoint or mounts still compile correctly. +` + + // Create temporary directory and file + tmpDir := testutil.TempDir(t, "default-test") + testFile := filepath.Join(tmpDir, "test-default.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + // Read the generated lock file + lockFile := stringutil.MarkdownToLockFile(testFile) + result, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + require.NotEmpty(t, result, "Compiled YAML should not be empty") + + // Convert to string for easier searching + yamlStr := string(result) + + // Should still have the MCP gateway setup but without custom entrypoint + // The default container should be present + assert.Contains(t, yamlStr, "ghcr.io/githubnext/gh-aw-mcpg", "Compiled YAML should contain default container") + + // Should have default mounts (from ensureDefaultMCPGatewayConfig) + assert.Contains(t, yamlStr, "-v", "Compiled YAML should contain volume mount flags for defaults") +} + +// TestMCPGatewayEntrypointWithSpecialCharacters tests entrypoint with special characters +func TestMCPGatewayEntrypointWithSpecialCharacters(t *testing.T) { + markdown := `--- +on: workflow_dispatch +engine: copilot +sandbox: + mcp: + container: ghcr.io/githubnext/gh-aw-mcpg + entrypoint: /usr/bin/env + entrypointArgs: + - bash + - -c + - "echo 'Hello World' && /app/start.sh" +--- + +# Test Workflow + +Test that entrypoint with special characters in args is properly handled. +` + + // Create temporary directory and file + tmpDir := testutil.TempDir(t, "special-chars-test") + testFile := filepath.Join(tmpDir, "test-special-chars.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + // Read the generated lock file + lockFile := stringutil.MarkdownToLockFile(testFile) + result, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + require.NotEmpty(t, result, "Compiled YAML should not be empty") + + // Convert to string for easier searching + yamlStr := string(result) + + // Verify entrypoint is present + assert.Contains(t, yamlStr, "--entrypoint", "Compiled YAML should contain --entrypoint flag") + assert.Contains(t, yamlStr, "/usr/bin/env", "Compiled YAML should contain entrypoint value") + + // Verify args with special characters are properly handled + assert.Contains(t, yamlStr, "bash", "Compiled YAML should contain bash arg") + // The exact format of the shell-quoted command may vary, but it should contain the key parts + assert.True(t, strings.Contains(yamlStr, "Hello World") || strings.Contains(yamlStr, "Hello\\ World"), + "Compiled YAML should contain the command string (possibly escaped)") +} + +// TestMCPGatewayMountsWithVariables tests mounts with environment variables +func TestMCPGatewayMountsWithVariables(t *testing.T) { + markdown := `--- +on: workflow_dispatch +engine: copilot +sandbox: + mcp: + container: ghcr.io/githubnext/gh-aw-mcpg + mounts: + - ${GITHUB_WORKSPACE}:/workspace:rw + - /tmp:/tmp:rw +--- + +# Test Workflow + +Test that mounts with environment variables are properly handled. +` + + // Create temporary directory and file + tmpDir := testutil.TempDir(t, "var-mounts-test") + testFile := filepath.Join(tmpDir, "test-var-mounts.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + // Read the generated lock file + lockFile := stringutil.MarkdownToLockFile(testFile) + result, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + require.NotEmpty(t, result, "Compiled YAML should not be empty") + + // Convert to string for easier searching + yamlStr := string(result) + + // Verify mounts with variables are present (they should be preserved as-is) + // The mount appears in the Docker command with quotes, so check for both formats + hasWorkspaceMount := strings.Contains(yamlStr, "${GITHUB_WORKSPACE}:/workspace:rw") || + strings.Contains(yamlStr, "'\"${GITHUB_WORKSPACE}\"':/workspace:rw") + assert.True(t, hasWorkspaceMount, "Compiled YAML should contain mount with environment variable") + assert.Contains(t, yamlStr, "/tmp:/tmp:rw", "Compiled YAML should contain regular mount") +} diff --git a/pkg/workflow/mcp_gateway_spec_fix_test.go b/pkg/workflow/mcp_gateway_spec_fix_test.go index 5e0635e9cd..5125dd2f23 100644 --- a/pkg/workflow/mcp_gateway_spec_fix_test.go +++ b/pkg/workflow/mcp_gateway_spec_fix_test.go @@ -70,9 +70,8 @@ func TestMCPServerEntrypointField(t *testing.T) { require.NotNil(t, extracted, "Extraction should not return nil") - // Note: This test will fail initially because Entrypoint field doesn't exist yet - // We'll add it as part of the fix - // assert.Equal(t, tt.expectEntrypoint, extracted.Entrypoint, "Entrypoint mismatch") + // Verify entrypoint extraction + assert.Equal(t, tt.expectEntrypoint, extracted.Entrypoint, "Entrypoint mismatch") assert.ElementsMatch(t, tt.expectEntrypointArgs, extracted.EntrypointArgs, "EntrypointArgs mismatch") }) } @@ -82,55 +81,80 @@ func TestMCPServerEntrypointField(t *testing.T) { func TestMCPServerMountsInServerConfig(t *testing.T) { tests := []struct { name string - toolsConfig map[string]any - serverName string + mcpConfig map[string]any expectMounts []string expectError bool }{ { name: "mcp server with mounts", - toolsConfig: map[string]any{ - "custom-server": map[string]any{ - "container": "ghcr.io/example/server:latest", - "mounts": []any{ - "/host/data:/container/data:ro", - "/host/config:/container/config:rw", - }, + mcpConfig: map[string]any{ + "container": "ghcr.io/example/server:latest", + "mounts": []any{ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw", }, }, - serverName: "custom-server", expectMounts: []string{"/host/data:/container/data:ro", "/host/config:/container/config:rw"}, expectError: false, }, { name: "mcp server without mounts", - toolsConfig: map[string]any{ - "simple-server": map[string]any{ - "container": "ghcr.io/example/simple:latest", - }, + mcpConfig: map[string]any{ + "container": "ghcr.io/example/simple:latest", }, - serverName: "simple-server", expectMounts: nil, expectError: false, }, + { + name: "mcp server with single mount", + mcpConfig: map[string]any{ + "container": "ghcr.io/example/server:latest", + "mounts": []any{ + "/tmp/data:/app/data:ro", + }, + }, + expectMounts: []string{"/tmp/data:/app/data:ro"}, + expectError: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Parse the tools config - toolsConfigStruct, err := ParseToolsConfig(tt.toolsConfig) - require.NoError(t, err, "Failed to parse tools config") + compiler := &Compiler{} + extracted := compiler.extractMCPGatewayConfig(tt.mcpConfig) - // Get the specific MCP server config - serverConfig, exists := toolsConfigStruct.Custom[tt.serverName] - require.True(t, exists, "Server not found in custom tools") + if tt.expectError { + // For now, we don't expect errors, but this is for future validation + return + } - // Note: This test will fail initially because Mounts field doesn't exist in MCPServerConfig - // We'll add it as part of the fix - // assert.ElementsMatch(t, tt.expectMounts, serverConfig.Mounts, "Mounts mismatch") + require.NotNil(t, extracted, "Extraction should not return nil") - // For now, just verify the server exists - _ = serverConfig + // Verify mounts extraction + assert.ElementsMatch(t, tt.expectMounts, extracted.Mounts, "Mounts mismatch") }) } } + +// TestMCPServerEntrypointAndMountsCombined tests entrypoint and mounts together in extraction +func TestMCPServerEntrypointAndMountsCombinedExtraction(t *testing.T) { + mcpConfig := map[string]any{ + "container": "ghcr.io/example/server:latest", + "entrypoint": "/usr/bin/custom-start", + "entrypointArgs": []any{"--config", "/etc/app.conf"}, + "mounts": []any{ + "/var/data:/app/data:rw", + "/etc/secrets:/app/secrets:ro", + }, + } + + compiler := &Compiler{} + extracted := compiler.extractMCPGatewayConfig(mcpConfig) + + require.NotNil(t, extracted, "Extraction should not return nil") + + // Verify all fields are extracted correctly + assert.Equal(t, "/usr/bin/custom-start", extracted.Entrypoint, "Entrypoint mismatch") + assert.ElementsMatch(t, []string{"--config", "/etc/app.conf"}, extracted.EntrypointArgs, "EntrypointArgs mismatch") + assert.ElementsMatch(t, []string{"/var/data:/app/data:rw", "/etc/secrets:/app/secrets:ro"}, extracted.Mounts, "Mounts mismatch") +} diff --git a/pkg/workflow/mcp_servers.go b/pkg/workflow/mcp_servers.go index 4cde25b2d3..65297ff386 100644 --- a/pkg/workflow/mcp_servers.go +++ b/pkg/workflow/mcp_servers.go @@ -598,6 +598,11 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, } } + // Add entrypoint override if specified + if gatewayConfig.Entrypoint != "" { + containerCmd += " --entrypoint " + shellQuote(gatewayConfig.Entrypoint) + } + containerCmd += " " + containerImage if len(gatewayConfig.EntrypointArgs) > 0 { diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 4fab77cf35..e919afd9f8 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -310,6 +310,7 @@ type MCPServerConfig struct { type MCPGatewayRuntimeConfig struct { Container string `yaml:"container,omitempty"` // Container image for the gateway (required) Version string `yaml:"version,omitempty"` // Optional version/tag for the container + Entrypoint string `yaml:"entrypoint,omitempty"` // Optional entrypoint override for the container Args []string `yaml:"args,omitempty"` // Arguments for docker run EntrypointArgs []string `yaml:"entrypointArgs,omitempty"` // Arguments passed to container entrypoint Env map[string]string `yaml:"env,omitempty"` // Environment variables for the gateway