diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 36d6000d785..9bba50a8012 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -289,6 +289,49 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_WorkflowDispatchNu } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_EngineDriverPattern(t *testing.T) { + t.Parallel() + + validFrontmatter := map[string]any{ + "on": "push", + "engine": map[string]any{ + "id": "claude", + "driver": "custom_driver.cjs", + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/engine-driver-valid-pattern-test.md") + if err != nil { + t.Fatalf("expected valid engine.driver pattern to pass schema validation, got: %v", err) + } + + invalidFrontmatter := map[string]any{ + "on": "push", + "engine": map[string]any{ + "id": "claude", + "driver": "../driver.cjs", + }, + } + + err = ValidateMainWorkflowFrontmatterWithSchemaAndLocation(invalidFrontmatter, "/tmp/gh-aw/engine-driver-invalid-pattern-test.md") + if err == nil { + t.Fatal("expected invalid engine.driver pattern to fail schema validation") + } + + invalidFlagLikeFrontmatter := map[string]any{ + "on": "push", + "engine": map[string]any{ + "id": "claude", + "driver": "-driver.cjs", + }, + } + + err = ValidateMainWorkflowFrontmatterWithSchemaAndLocation(invalidFlagLikeFrontmatter, "/tmp/gh-aw/engine-driver-invalid-flaglike-pattern-test.md") + if err == nil { + t.Fatal("expected flag-like engine.driver pattern to fail schema validation") + } +} + func TestMainWorkflowSchema_WorkflowDispatchNumberTypeDocumentation(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 60717883952..7bf6307ba24 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9497,6 +9497,11 @@ "type": "string", "description": "Custom executable path for the AI engine CLI. When specified, the workflow will skip the standard installation steps and use this command instead. The command should be the full path to the executable or a command available in PATH." }, + "driver": { + "type": "string", + "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]*\\.(?:js|cjs|mjs)$", + "description": "Custom Node.js driver script filename for an agentic engine. This replaces the engine's built-in driver wrapper (when the engine supports one) and must end with .js, .cjs, or .mjs." + }, "env": { "type": "object", "description": "Custom environment variables to pass to the AI engine, including secret overrides (e.g., OPENAI_API_KEY: ${{ secrets.CUSTOM_KEY }})", diff --git a/pkg/workflow/action_pins_test.go b/pkg/workflow/action_pins_test.go index 0e9a74a578d..4d791589876 100644 --- a/pkg/workflow/action_pins_test.go +++ b/pkg/workflow/action_pins_test.go @@ -13,6 +13,21 @@ import ( "github.com/github/gh-aw/pkg/testutil" ) +const setupNodeV6ExpectedUsesPlaceholder = "__setup_node_v6__" + +func expectedPinnedUses(t *testing.T, repo, version string) string { + t.Helper() + + result, err := getActionPinWithData(repo, version, &WorkflowData{}) + if err != nil { + t.Fatalf("getActionPinWithData(%s, %s) returned error: %v", repo, version, err) + } + if result == "" { + t.Fatalf("getActionPinWithData(%s, %s) returned empty result", repo, version) + } + return result +} + // TestActionPinsExist verifies that all action pinning entries exist func TestActionPinsExist(t *testing.T) { // Read action pins from JSON file instead of hardcoded list @@ -215,7 +230,7 @@ func TestApplyActionPinToStep(t *testing.T) { }, }, expectPinned: true, - expectedUses: "actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6", + expectedUses: setupNodeV6ExpectedUsesPlaceholder, }, { name: "step with unpinned action", @@ -274,8 +289,12 @@ func TestApplyActionPinToStep(t *testing.T) { return } - if usesStr != tt.expectedUses { - t.Errorf("applyActionPinToTypedStep uses = %q, want %q", usesStr, tt.expectedUses) + expectedUses := tt.expectedUses + if expectedUses == setupNodeV6ExpectedUsesPlaceholder { + expectedUses = expectedPinnedUses(t, "actions/setup-node", "v6") + } + if usesStr != expectedUses { + t.Errorf("applyActionPinToTypedStep uses = %q, want %q", usesStr, expectedUses) } // Verify other fields are preserved (check length and keys) @@ -344,22 +363,23 @@ func TestGetActionPinsSorting(t *testing.T) { // TestGetActionPinByRepo tests the getActionPinByRepo function func TestGetActionPinByRepo(t *testing.T) { tests := []struct { - repo string - expectExists bool - expectRepo string - expectVer string + repo string + expectExists bool + expectRepo string + expectVersion string + expectVersionPrefix string }{ { - repo: "actions/checkout", - expectExists: true, - expectRepo: "actions/checkout", - expectVer: "v6.0.2", + repo: "actions/checkout", + expectExists: true, + expectRepo: "actions/checkout", + expectVersion: "v6.0.2", }, { - repo: "actions/setup-node", - expectExists: true, - expectRepo: "actions/setup-node", - expectVer: "v6.3.0", + repo: "actions/setup-node", + expectExists: true, + expectRepo: "actions/setup-node", + expectVersionPrefix: "v6.", }, { repo: "unknown/action", @@ -373,6 +393,10 @@ func TestGetActionPinByRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.repo, func(t *testing.T) { + if tt.expectVersion != "" && tt.expectVersionPrefix != "" { + t.Fatalf("invalid test case: expectVersion and expectVersionPrefix are mutually exclusive") + } + pin, exists := getActionPinByRepo(tt.repo) if exists != tt.expectExists { @@ -383,8 +407,11 @@ func TestGetActionPinByRepo(t *testing.T) { if pin.Repo != tt.expectRepo { t.Errorf("getActionPinByRepo(%s) repo = %s, want %s", tt.repo, pin.Repo, tt.expectRepo) } - if pin.Version != tt.expectVer { - t.Errorf("getActionPinByRepo(%s) version = %s, want %s", tt.repo, pin.Version, tt.expectVer) + if tt.expectVersion != "" && pin.Version != tt.expectVersion { + t.Errorf("getActionPinByRepo(%s) version = %s, want %s", tt.repo, pin.Version, tt.expectVersion) + } + if tt.expectVersionPrefix != "" && !strings.HasPrefix(pin.Version, tt.expectVersionPrefix) { + t.Errorf("getActionPinByRepo(%s) version = %s, want prefix %s", tt.repo, pin.Version, tt.expectVersionPrefix) } if !isValidSHA(pin.SHA) { t.Errorf("getActionPinByRepo(%s) has invalid SHA: %s", tt.repo, pin.SHA) @@ -421,7 +448,7 @@ func TestApplyActionPinToTypedStep(t *testing.T) { }, }, expectPinned: true, - expectedUses: "actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6", + expectedUses: setupNodeV6ExpectedUsesPlaceholder, }, { name: "step with unpinned action", @@ -484,8 +511,12 @@ func TestApplyActionPinToTypedStep(t *testing.T) { } // Check uses field - if result.Uses != tt.expectedUses { - t.Errorf("applyActionPinToTypedStep() uses = %q, want %q", result.Uses, tt.expectedUses) + expectedUses := tt.expectedUses + if expectedUses == setupNodeV6ExpectedUsesPlaceholder { + expectedUses = expectedPinnedUses(t, "actions/setup-node", "v6") + } + if result.Uses != expectedUses { + t.Errorf("applyActionPinToTypedStep() uses = %q, want %q", result.Uses, expectedUses) } // Verify other fields are preserved @@ -1314,7 +1345,7 @@ func TestMapToStepWithActionPinning(t *testing.T) { }, }, wantErr: false, - expectedUses: "actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6", + expectedUses: setupNodeV6ExpectedUsesPlaceholder, }, } @@ -1338,9 +1369,13 @@ func TestMapToStepWithActionPinning(t *testing.T) { } // Verify the result - if tt.expectedUses != "" { - if pinnedStep.Uses != tt.expectedUses { - t.Errorf("pinnedStep.Uses = %q, want %q", pinnedStep.Uses, tt.expectedUses) + expectedUses := tt.expectedUses + if expectedUses == setupNodeV6ExpectedUsesPlaceholder { + expectedUses = expectedPinnedUses(t, "actions/setup-node", "v6") + } + if expectedUses != "" { + if pinnedStep.Uses != expectedUses { + t.Errorf("pinnedStep.Uses = %q, want %q", pinnedStep.Uses, expectedUses) } } diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 2f662c3a3fa..60691a8384c 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -76,6 +76,11 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) return nil, fmt.Errorf("%s: %w", cleanPath, err) } + // Validate optional custom engine driver script configuration. + if err := c.validateEngineDriverScript(workflowData); err != nil { + return nil, fmt.Errorf("%s: %w", cleanPath, err) + } + // Validate that inlined-imports is not used with agent file imports. // Agent files require runtime access and cannot be resolved without sources. if workflowData.InlinedImports && engineSetup.importsResult.AgentFile != "" { diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index 68d000c29c2..cd24bc19f5c 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -191,6 +191,9 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st // - Fall back to `command -v node` if GH_AW_NODE_BIN points to a non-mounted toolcache path. // This prevents agent startup failures when host toolcache paths are not present in the AWF container. driverScriptName := e.GetDriverScriptName() + if workflowData.EngineConfig != nil && workflowData.EngineConfig.DriverScript != "" { + driverScriptName = workflowData.EngineConfig.DriverScript + } var execPrefix string if driverScriptName != "" { // Driver wraps the copilot subprocess; ${RUNNER_TEMP} and ${GH_AW_NODE_BIN} expand in the shell context. diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index b1c0db64da2..0735ea174da 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -1729,6 +1729,31 @@ func TestCopilotEngineDriverScript(t *testing.T) { } }) + t.Run("Execution step uses configured custom driver instead of built-in", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + DriverScript: "custom_copilot_driver.cjs", + }, + Tools: make(map[string]any), + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log") + if len(steps) == 0 { + t.Fatal("Expected at least one step") + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + if !strings.Contains(stepContent, "custom_copilot_driver.cjs") { + t.Errorf("Expected custom driver in execution step, got:\n%s", stepContent) + } + if strings.Contains(stepContent, "actions/copilot_driver.cjs") { + t.Errorf("Expected built-in driver to be replaced, got:\n%s", stepContent) + } + }) + t.Run("CopilotEngine implements DriverProvider interface", func(t *testing.T) { var _ DriverProvider = engine }) diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 5e067b5507f..dbe5758c233 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -23,6 +23,7 @@ type EngineConfig struct { Concurrency string // Agent job-level concurrency configuration (YAML format) UserAgent string Command string // Custom executable path (when set, skip installation steps) + DriverScript string // Custom Node.js driver script filename (replaces engine default driver script when supported) Env map[string]string Config string Args []string @@ -245,6 +246,13 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } + // Extract optional 'driver' field (string - validated separately) + if driver, hasDriver := engineObj["driver"]; hasDriver { + if driverStr, ok := driver.(string); ok { + config.DriverScript = driverStr + } + } + // Extract optional 'env' field (object/map of strings) if env, hasEnv := engineObj["env"]; hasEnv { if envMap, ok := env.(map[string]any); ok { diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index df257648344..871e270eb8f 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -207,6 +207,17 @@ func TestExtractEngineConfig(t *testing.T) { expectedEngineSetting: "codex", expectedConfig: &EngineConfig{ID: "codex", UserAgent: "my-custom-agent-hyphen"}, }, + { + name: "object format - with copilot driver script", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "copilot", + "driver": "custom_copilot_driver.cjs", + }, + }, + expectedEngineSetting: "copilot", + expectedConfig: &EngineConfig{ID: "copilot", DriverScript: "custom_copilot_driver.cjs"}, + }, { name: "object format - complete with user-agent", frontmatter: map[string]any{ @@ -264,6 +275,10 @@ func TestExtractEngineConfig(t *testing.T) { t.Errorf("Expected config.UserAgent '%s', got '%s'", test.expectedConfig.UserAgent, config.UserAgent) } + if config.DriverScript != test.expectedConfig.DriverScript { + t.Errorf("Expected config.DriverScript '%s', got '%s'", test.expectedConfig.DriverScript, config.DriverScript) + } + if len(config.Env) != len(test.expectedConfig.Env) { t.Errorf("Expected config.Env length %d, got %d", len(test.expectedConfig.Env), len(config.Env)) } else { diff --git a/pkg/workflow/engine_validation.go b/pkg/workflow/engine_validation.go index 9926ed8e041..863afa9efe3 100644 --- a/pkg/workflow/engine_validation.go +++ b/pkg/workflow/engine_validation.go @@ -37,6 +37,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "regexp" "strings" "github.com/github/gh-aw/pkg/console" @@ -45,6 +47,7 @@ import ( ) var engineValidationLog = newValidationLogger("engine") +var safeDriverScriptPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`) // validateEngineVersion warns (non-strict) or errors (strict) when the workflow // explicitly pins the engine CLI to "latest". Unpinned "latest" versions change @@ -75,6 +78,35 @@ func (c *Compiler) validateEngineVersion(workflowData *WorkflowData) error { return nil } +// validateEngineDriverScript validates optional engine.driver configuration. +// engine.driver must point to a Node.js script. +func (c *Compiler) validateEngineDriverScript(workflowData *WorkflowData) error { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.DriverScript == "" { + return nil + } + + driverScript := workflowData.EngineConfig.DriverScript + if strings.TrimSpace(driverScript) != driverScript { + return fmt.Errorf("engine.driver must be a safe basename without leading/trailing whitespace (found: %s).\n\nSee: %s", workflowData.EngineConfig.DriverScript, constants.DocsEnginesURL) + } + + if filepath.IsAbs(driverScript) || + strings.Contains(driverScript, "/") || + strings.Contains(driverScript, `\`) || + strings.Contains(driverScript, "..") || + !safeDriverScriptPattern.MatchString(driverScript) { + return fmt.Errorf("engine.driver must be a safe basename (no path separators, '..', or shell metacharacters) ending with .js, .cjs, or .mjs (found: %s).\n\nSee: %s", workflowData.EngineConfig.DriverScript, constants.DocsEnginesURL) + } + + ext := strings.ToLower(filepath.Ext(driverScript)) + switch ext { + case ".js", ".cjs", ".mjs": + return nil + default: + return fmt.Errorf("engine.driver must be a Node.js script ending with .js, .cjs, or .mjs (found: %s).\n\nSee: %s", workflowData.EngineConfig.DriverScript, constants.DocsEnginesURL) + } +} + // validateEngineInlineDefinition validates an inline engine definition parsed from // engine.runtime + optional engine.provider in the workflow frontmatter. // Returns an error if: diff --git a/pkg/workflow/engine_validation_test.go b/pkg/workflow/engine_validation_test.go index 6d19677f9cc..f8105922a85 100644 --- a/pkg/workflow/engine_validation_test.go +++ b/pkg/workflow/engine_validation_test.go @@ -5,6 +5,9 @@ package workflow import ( "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestValidateSingleEngineSpecification tests the validateSingleEngineSpecification function @@ -288,3 +291,113 @@ func TestValidateEngineVersion(t *testing.T) { }) } } + +func TestValidateEngineDriverScript(t *testing.T) { + tests := []struct { + name string + workflow *WorkflowData + expectError bool + errorSubstr string + }{ + { + name: "no engine config", + workflow: &WorkflowData{ + EngineConfig: nil, + }, + expectError: false, + }, + { + name: "no driver configured", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + }, + expectError: false, + }, + { + name: "valid cjs driver on copilot", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "custom_driver.cjs"}, + }, + expectError: false, + }, + { + name: "valid mjs driver on copilot", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "custom_driver.mjs"}, + }, + expectError: false, + }, + { + name: "invalid extension", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "driver.sh"}, + }, + expectError: true, + errorSubstr: "must be a Node.js script", + }, + { + name: "driver configured for any engine", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "claude", DriverScript: "driver.cjs"}, + }, + expectError: false, + }, + { + name: "invalid path traversal", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "../driver.cjs"}, + }, + expectError: true, + errorSubstr: "safe basename", + }, + { + name: "invalid path separator", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "nested/driver.cjs"}, + }, + expectError: true, + errorSubstr: "safe basename", + }, + { + name: "invalid shell metacharacter", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "driver;rm -rf /.cjs"}, + }, + expectError: true, + errorSubstr: "safe basename", + }, + { + name: "invalid leading whitespace", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: " driver.cjs"}, + }, + expectError: true, + errorSubstr: "leading/trailing whitespace", + }, + { + name: "invalid leading hyphen", + workflow: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot", DriverScript: "-driver.cjs"}, + }, + expectError: true, + errorSubstr: "safe basename", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + err := compiler.validateEngineDriverScript(tt.workflow) + + if tt.expectError { + require.Error(t, err, "Expected validation error") + if tt.errorSubstr != "" { + assert.Contains(t, err.Error(), tt.errorSubstr, "Expected error substring mismatch") + } + return + } + + assert.NoError(t, err, "Expected driver validation to pass") + }) + } +} diff --git a/pkg/workflow/runtime_detection.go b/pkg/workflow/runtime_detection.go index ef6f420757b..bfe83142589 100644 --- a/pkg/workflow/runtime_detection.go +++ b/pkg/workflow/runtime_detection.go @@ -4,6 +4,7 @@ import ( "sort" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/semverutil" ) @@ -38,6 +39,16 @@ func DetectRuntimeRequirements(workflowData *WorkflowData) []RuntimeRequirement } } + // When a custom driver script is configured for an engine that currently supports + // driver wrappers, require Node.js runtime setup with the default version so workflows + // consistently execute the driver with Node 24. + if requiresNodeForEngineDriver(workflowData) { + nodeRuntime := findRuntimeByID("node") + if nodeRuntime != nil { + updateRequiredRuntime(nodeRuntime, string(constants.DefaultNodeVersion), requirements) + } + } + // Apply runtime overrides from frontmatter if workflowData.Runtimes != nil { applyRuntimeOverrides(workflowData.Runtimes, requirements) @@ -77,6 +88,27 @@ func DetectRuntimeRequirements(workflowData *WorkflowData) []RuntimeRequirement return result } +// requiresNodeForEngineDriver returns true when workflow runtime setup must ensure Node.js +// for engine.driver execution based on current engine wrapper support. +func requiresNodeForEngineDriver(workflowData *WorkflowData) bool { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.DriverScript == "" { + return false + } + + engineID := workflowData.EngineConfig.ID + if engineID == "" { + engineID = workflowData.AI + } + if engineID == "" { + engineID = string(constants.DefaultEngine) + } + + // Today only Copilot consumes engine.driver in execution command generation. + // Keep runtime setup scoped to Copilot until additional engines implement + // driver wrapper execution paths. + return strings.EqualFold(engineID, string(constants.CopilotEngine)) +} + // detectFromCustomSteps scans custom steps YAML for runtime commands func detectFromCustomSteps(customSteps string, requirements map[string]*RuntimeRequirement) { log.Print("Scanning custom steps for runtime commands") diff --git a/pkg/workflow/runtime_setup_test.go b/pkg/workflow/runtime_setup_test.go index f77faf12088..f28ef5c9072 100644 --- a/pkg/workflow/runtime_setup_test.go +++ b/pkg/workflow/runtime_setup_test.go @@ -8,7 +8,9 @@ import ( "strings" "testing" + "github.com/github/gh-aw/pkg/constants" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDetectRuntimeFromCommand(t *testing.T) { @@ -1041,3 +1043,45 @@ func TestDetectRuntimeRequirements_CustomImageRunner(t *testing.T) { }) } } + +func TestDetectRuntimeRequirements_CustomDriverAddsNode24(t *testing.T) { + data := &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", + EngineConfig: &EngineConfig{ + ID: "copilot", + DriverScript: "custom_driver.cjs", + }, + } + + requirements := DetectRuntimeRequirements(data) + + var nodeReq *RuntimeRequirement + for i := range requirements { + if requirements[i].Runtime != nil && requirements[i].Runtime.ID == "node" { + nodeReq = &requirements[i] + break + } + } + + require.NotNil(t, nodeReq, "Expected Node.js runtime requirement when custom copilot driver is configured") + + assert.Equal(t, string(constants.DefaultNodeVersion), nodeReq.Version, "Custom engine driver should require Node.js 24 runtime") +} + +func TestDetectRuntimeRequirements_CustomDriverDoesNotAddNodeForNonCopilotEngine(t *testing.T) { + data := &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", + EngineConfig: &EngineConfig{ + ID: "claude", + DriverScript: "custom_driver.cjs", + }, + } + + requirements := DetectRuntimeRequirements(data) + + for _, req := range requirements { + if req.Runtime != nil && req.Runtime.ID == "node" { + t.Fatalf("Expected no Node.js runtime requirement for non-Copilot engine, got version %q", req.Version) + } + } +}