Skip to content
3 changes: 1 addition & 2 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
60 changes: 54 additions & 6 deletions pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_orchestrator_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions pkg/workflow/imports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-*")
Expand Down
22 changes: 22 additions & 0 deletions pkg/workflow/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions pkg/workflow/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading