From 0959feaf81974f939a84ea5b5f6cca733bb7f8c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:38:07 +0000 Subject: [PATCH 1/2] Initial plan From 651e19dc46c1d1a24567ea9768200e0438395e15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:04:17 +0000 Subject: [PATCH 2/2] feat: allow shared sandbox mounts and merge with main workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/constants/constants.go | 3 +- pkg/parser/import_field_extractor.go | 60 +++++++++++-- pkg/parser/import_processor.go | 1 + pkg/workflow/compiler_orchestrator_engine.go | 1 + pkg/workflow/imports_test.go | 90 ++++++++++++++++++++ pkg/workflow/sandbox.go | 22 +++++ pkg/workflow/sandbox_test.go | 74 ++++++++++++++++ 7 files changed, 243 insertions(+), 8 deletions(-) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index dc62ebd0735..5a7f6de08b4 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -360,7 +360,7 @@ var IgnoredFrontmatterFields = []string{} // - Workflow triggers: on (defines it as a main workflow) // - Workflow execution: run-name, runs-on, concurrency, if, timeout-minutes // - Workflow metadata: name, tracker-id, strict -// - Workflow features: container, environment, sandbox, features +// - Workflow features: container, environment, features // - Access control: github-token // // All other fields defined in main_workflow_schema.json can be used in shared workflows @@ -376,7 +376,6 @@ var SharedWorkflowForbiddenFields = []string{ "name", // Workflow name "run-name", // Run display name "runs-on", // Runner specification - "sandbox", // Sandbox configuration "strict", // Strict mode "timeout-minutes", // Timeout in minutes "tracker-id", // Tracker ID diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index b2a22910923..e37ac2e8d0d 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -50,6 +50,8 @@ type importAccumulator struct { skipBotsSet map[string]bool skipIfMatch string skipIfNoMatch string + sandboxAgentMounts []string + sandboxAgentMountsSet map[string]bool caches []string features []map[string]any models []map[string][]string // model alias maps from each imported file (appended in import order) @@ -97,12 +99,13 @@ const ( // during deduplication. Slices are left as nil, which is valid for append operations. func newImportAccumulator() *importAccumulator { return &importAccumulator{ - botsSet: make(map[string]bool), - labelsSet: make(map[string]bool), - skipRolesSet: make(map[string]bool), - skipBotsSet: make(map[string]bool), - importInputs: make(map[string]any), - envSources: make(map[string]string), + botsSet: make(map[string]bool), + labelsSet: make(map[string]bool), + skipRolesSet: make(map[string]bool), + skipBotsSet: make(map[string]bool), + importInputs: make(map[string]any), + envSources: make(map[string]string), + sandboxAgentMountsSet: make(map[string]bool), } } @@ -378,10 +381,54 @@ func (acc *importAccumulator) extractConfigFields(fm map[string]any, fullPath st acc.appendJSONBuilderField(fm, "runtimes", "{}", &acc.runtimesBuilder) acc.appendYAMLBuilderField(fm, "services", &acc.servicesBuilder) acc.appendJSONBuilderField(fm, "network", "{}", &acc.networkBuilder) + acc.mergeSandboxAgentMounts(fm) acc.appendJSONBuilderField(fm, "permissions", "{}", &acc.permissionsBuilder) acc.appendJSONBuilderField(fm, "secret-masking", "{}", &acc.secretMaskingBuilder) } +func (acc *importAccumulator) mergeSandboxAgentMounts(fm map[string]any) { + sandboxVal, hasSandbox := fm["sandbox"] + if !hasSandbox { + return + } + + sandboxMap, ok := sandboxVal.(map[string]any) + if !ok { + return + } + + agentVal, hasAgent := sandboxMap["agent"] + if !hasAgent { + return + } + + agentMap, ok := agentVal.(map[string]any) + if !ok { + return + } + + mountsVal, hasMounts := agentMap["mounts"] + if !hasMounts { + return + } + + mounts, ok := mountsVal.([]any) + if !ok { + return + } + + for _, mountVal := range mounts { + mount, ok := mountVal.(string) + if !ok || mount == "" { + continue + } + if !acc.sandboxAgentMountsSet[mount] { + acc.sandboxAgentMountsSet[mount] = true + acc.sandboxAgentMounts = append(acc.sandboxAgentMounts, mount) + } + } +} + func (acc *importAccumulator) extractFirstWinsJSONField(fm map[string]any, fullPath, field string, target *string) { if *target != "" { return @@ -826,6 +873,7 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import MergedRunInstallScripts: acc.runInstallScripts, MergedServices: acc.servicesBuilder.String(), MergedNetwork: acc.networkBuilder.String(), + MergedSandboxAgentMounts: acc.sandboxAgentMounts, MergedPermissions: acc.permissionsBuilder.String(), MergedSecretMasking: acc.secretMaskingBuilder.String(), MergedBots: acc.bots, diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index c5b5949db00..5bd0da66b45 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -40,6 +40,7 @@ type ImportsResult struct { MergedRunInstallScripts bool // true if any imported workflow sets runtimes.node.run-install-scripts: true MergedServices string // Merged services configuration from all imports MergedNetwork string // Merged network configuration from all imports + MergedSandboxAgentMounts []string // Merged sandbox.agent.mounts from all imports (union, deduplicated) MergedPermissions string // Merged permissions configuration from all imports MergedSecretMasking string // Merged secret-masking steps from all imports MergedBots []string // Merged bots list from all imports (union of bot names) diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go index 0d5bc3bc3a7..29bd07960bf 100644 --- a/pkg/workflow/compiler_orchestrator_engine.go +++ b/pkg/workflow/compiler_orchestrator_engine.go @@ -63,6 +63,7 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean if err != nil { return nil, err } + sandboxConfig = mergeImportedSandboxAgentMounts(sandboxConfig, importsResult.MergedSandboxAgentMounts) engineSetting, engineConfig, err = c.resolveEngineFromIncludesAndImports(result, markdownDir, importsResult, engineSetting, engineConfig) if err != nil { return nil, err diff --git a/pkg/workflow/imports_test.go b/pkg/workflow/imports_test.go index 631e5f00585..5bdbcdad97e 100644 --- a/pkg/workflow/imports_test.go +++ b/pkg/workflow/imports_test.go @@ -226,6 +226,96 @@ Main workflow body. } } +func TestCompileWorkflowWithImportedSandboxMounts(t *testing.T) { + tempDir := testutil.TempDir(t, "test-imported-sandbox-mounts-*") + workflowsDir := filepath.Join(tempDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + sharedAPath := filepath.Join(workflowsDir, "shared-a.md") + sharedAContent := `--- +sandbox: + agent: + mounts: + - /tool-a/bin/my-cli:/tool-a/bin/my-cli:ro + - /shared/bin/tool:/shared/bin/tool:ro +--- + +# Shared A +` + if err := os.WriteFile(sharedAPath, []byte(sharedAContent), 0644); err != nil { + t.Fatalf("Failed to write shared A workflow: %v", err) + } + + sharedBPath := filepath.Join(workflowsDir, "shared-b.md") + sharedBContent := `--- +sandbox: + agent: + mounts: + - /tool-b/bin/other-cli:/tool-b/bin/other-cli:ro + - /shared/bin/tool:/shared/bin/tool:ro +--- + +# Shared B +` + if err := os.WriteFile(sharedBPath, []byte(sharedBContent), 0644); err != nil { + t.Fatalf("Failed to write shared B workflow: %v", err) + } + + workflowPath := filepath.Join(workflowsDir, "main.md") + workflowContent := `--- +on: issues +permissions: + contents: read + issues: read +engine: copilot +imports: + - ./shared-a.md + - ./shared-b.md +sandbox: + agent: + mounts: + - /main/bin/main-cli:/main/bin/main-cli:ro + - /shared/bin/tool:/shared/bin/tool:ro +--- + +# Main Workflow + +Validate imported sandbox mounts. +` + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write main workflow: %v", err) + } + + compiler := workflow.NewCompiler() + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("CompileWorkflow failed: %v", err) + } + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockFileContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + compiled := string(lockFileContent) + + for _, mount := range []string{ + "--mount /tool-a/bin/my-cli:/tool-a/bin/my-cli:ro", + "--mount /tool-b/bin/other-cli:/tool-b/bin/other-cli:ro", + "--mount /main/bin/main-cli:/main/bin/main-cli:ro", + } { + if !strings.Contains(compiled, mount) { + t.Errorf("Expected compiled workflow to contain mount %q", mount) + } + } + + if count := strings.Count(compiled, "--mount /shared/bin/tool:/shared/bin/tool:ro"); count != 1 { + t.Errorf("Expected deduplicated shared mount exactly once, got %d", count) + } +} + func TestCompileWorkflowWithMCPServersImport(t *testing.T) { // Create a temporary directory for test files tempDir := testutil.TempDir(t, "test-*") diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index a6ae050f4d4..c1f9412f83c 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -17,6 +17,7 @@ import ( "slices" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/sliceutil" ) var sandboxLog = logger.New("workflow:sandbox") @@ -237,6 +238,27 @@ func ensureDefaultAgentWritePath(sandboxConfig *SandboxConfig) { ) } +func mergeImportedSandboxAgentMounts(sandboxConfig *SandboxConfig, importedMounts []string) *SandboxConfig { + if len(importedMounts) == 0 { + return sandboxConfig + } + + if sandboxConfig == nil { + sandboxConfig = &SandboxConfig{} + } + + if sandboxConfig.Agent != nil && sandboxConfig.Agent.Disabled { + return sandboxConfig + } + + if sandboxConfig.Agent == nil { + sandboxConfig.Agent = &AgentSandboxConfig{} + } + + sandboxConfig.Agent.Mounts = sliceutil.MergeUnique(importedMounts, sandboxConfig.Agent.Mounts...) + return sandboxConfig +} + // isSandboxEnabled checks if the sandbox is enabled (either explicitly or auto-enabled) // Returns true when: // - sandbox.agent is explicitly set to awf diff --git a/pkg/workflow/sandbox_test.go b/pkg/workflow/sandbox_test.go index 21785cc8576..b4efeb013cc 100644 --- a/pkg/workflow/sandbox_test.go +++ b/pkg/workflow/sandbox_test.go @@ -267,6 +267,80 @@ func TestApplySandboxDefaults(t *testing.T) { } } +func TestMergeImportedSandboxAgentMounts(t *testing.T) { + tests := []struct { + name string + initial *SandboxConfig + imported []string + expected []string + expectNil bool + expectDisabled bool + }{ + { + name: "no imported mounts returns original nil config", + initial: nil, + imported: nil, + expectNil: true, + }, + { + name: "creates sandbox agent config from imports", + initial: nil, + imported: []string{"/tool-a:/tool-a:ro"}, + expected: []string{"/tool-a:/tool-a:ro"}, + }, + { + name: "deduplicates imported and main mounts", + initial: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Mounts: []string{ + "/main:/main:ro", + "/shared:/shared:ro", + }, + }, + }, + imported: []string{ + "/shared:/shared:ro", + "/import-a:/import-a:ro", + }, + expected: []string{ + "/shared:/shared:ro", + "/import-a:/import-a:ro", + "/main:/main:ro", + }, + }, + { + name: "does not modify disabled agent sandbox", + initial: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Disabled: true, + }, + }, + imported: []string{"/tool-a:/tool-a:ro"}, + expectDisabled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + merged := mergeImportedSandboxAgentMounts(tt.initial, tt.imported) + + if tt.expectNil { + assert.Nil(t, merged) + return + } + + require.NotNil(t, merged) + require.NotNil(t, merged.Agent) + if tt.expectDisabled { + assert.True(t, merged.Agent.Disabled) + assert.Empty(t, merged.Agent.Mounts) + return + } + assert.Equal(t, tt.expected, merged.Agent.Mounts) + }) + } +} + func TestDefaultAgentWorkspaceWritePath(t *testing.T) { assert.Equal(t, "/tmp/gh-aw/agent", defaultAgentWorkspaceWritePath) }