Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR-49448: Extract sandbox.agent.memory from Frontmatter with Compile-Time Format Validation

**Date**: 2026-08-01
**Status**: Draft
**Deciders**: Unknown

---

### Context

The `sandbox.agent.memory` field was declared in the AWF workflow schema and consumed by the AWF arg builder to produce `--memory-limit`. However, `extractAgentSandboxConfig` never assigned the field, so `--memory-limit` was silently dropped from every compiled lock file. Users who set `sandbox.agent.memory` saw no error, no warning, and no effect — the field appeared functional in documentation but was effectively broken. Without memory overrides, large-memory build tools (MSBuild, JVM processes) hit the default container memory cap and were killed with exit code 137.

### Decision

We will complete the extraction of `sandbox.agent.memory` inside `extractAgentSandboxConfig`, mirroring the existing `mounts`/`runtime` extraction pattern, and add a dedicated `validateAgentMemoryLimit` function that reuses the existing `memoryLimitPattern` regex to reject malformed values (e.g. `48gb`, `48`) at compile time rather than at execution time.

### Alternatives Considered

#### Alternative 1: Leave the silent drop in place (status quo)

The field remains schema-documented but has no effect. Users who configure memory limits see no error and receive no benefit. This is the current accidental state — it is not a deliberate choice and provides no value; keeping it active would be actively misleading.

#### Alternative 2: Extract the field but defer validation to AWF at execution time

The extraction step is added but no compile-time validation is performed; AWF is responsible for rejecting invalid format strings when it starts the container. This delays error detection to workflow execution, producing a runtime failure rather than a compile-time failure, and makes the error message less actionable since it surfaces far from the authoring step.

#### Alternative 3: Extract and validate at compile time (chosen)

Extraction is added alongside a compile-time validator that matches the pattern already used for bounded-query memory limits. Invalid formats are rejected at `gh aw compile` time with a descriptive error pointing to the docs. This is consistent with how other `sandbox` sub-fields are validated and provides the best developer experience.

### Consequences

#### Positive
- `sandbox.agent.memory` now propagates correctly to AWF as `--memory-limit`, making the feature functional.
- Malformed memory values are caught at compile time with a clear, actionable error message, consistent with the existing validation philosophy for sandbox fields.
- The fix reuses the existing `memoryLimitPattern`, so no new regex is introduced.

#### Negative
- `validateAgentMemoryLimit` is a thin wrapper around `memoryLimitPattern`; if the pattern's semantics change, callers must be updated consistently.
- Any workflow that previously silently had `sandbox.agent.memory` set to an invalid string will now fail at compile time — a breaking change for malformed configs, though this surfaces a pre-existing latent error.

#### Neutral
- Test coverage is added for the extraction path (valid string, absent, non-string type) and for the validation function (valid units, invalid suffixes, leading zeros, zero value).
- Documentation in `docs/reference/sandbox.md` now includes valid format examples and an exit-137 note.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
17 changes: 17 additions & 0 deletions docs/src/content/docs/reference/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@ jobs:
Use `go build` or `python3` - both are available.
```

#### Memory Limit (`sandbox.agent.memory`)

By default, AWF uses its own built-in memory limit for the agent container. Set `sandbox.agent.memory` to override this limit on large-memory runners:

```yaml wrap
sandbox:
agent:
memory: 8g
```

Valid values are a positive integer followed by a unit: `b`, `k`, `m`, or `g` (case-insensitive). Examples: `512m`, `4g`, `8g`, `1024m`.

When omitted, AWF's own default memory limit applies. Specifying an invalid format (e.g., `48gb` or `48`) is rejected at compile time.

> [!NOTE]
> Exit code 137 means the process received `SIGKILL`. A memory limit can be one cause, but verify with logs before changing `memory`. If you increase `memory`, leave headroom for the runner OS and other processes.

#### Copilot BYOK request customization (`sandbox.agent.targets.copilot`)

When routing Copilot through a BYOK-compatible upstream behind the AWF proxy, you can attach custom headers, extra request body fields, and an explicit session identifier on upstream requests:
Expand Down
2 changes: 1 addition & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3483,7 +3483,7 @@
"memory": {
"type": "string",
"description": "Memory limit for the AWF container (e.g., '4g', '8g'). Passed as --memory-limit to AWF. If not specified, AWF's default memory limit is used.",
"pattern": "^[0-9]+(b|k|m|g|kb|mb|gb|B|K|M|G|KB|MB|GB)$",
"pattern": "^[1-9][0-9]*[bkmgBKMG]$",
"examples": ["4g", "8g", "512m"]
},
"model-fallback": {
Expand Down
8 changes: 8 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig {
}
}

// Extract memory (memory limit for the AWF container)
if memoryVal, hasMemory := agentObj["memory"]; hasMemory {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-string memory values (e.g. unquoted YAML memory: 48) are silently dropped — no warning, no error — and validation is skipped entirely.

💡 Details

if memoryStr, ok := memoryVal.(string); ok { ... } silently no-ops when the type assertion fails. A very plausible authoring mistake — writing memory: 48 instead of memory: "48g" — parses as a YAML int, fails the type check, and agentConfig.Memory stays empty. Since validateAgentMemoryLimit is only invoked when Memory != "", this bad input never triggers the intended validation error; the workflow just compiles with the default AWF memory limit and no diagnostic at all, silently defeating the entire feature this PR is meant to fix.

Suggested fix: when hasMemory is true but the type assertion fails, return/record a validation error (mirroring how invalid string values are already caught by validateAgentMemoryLimit) rather than silently ignoring it.

if memoryStr, ok := memoryVal.(string); ok {
agentConfig.Memory = memoryStr
frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.memory: %s", memoryStr)
}
}

// Extract runtime (container runtime for the agent container)
if runtimeVal, hasRuntime := agentObj["runtime"]; hasRuntime {
if runtimeStr, ok := runtimeVal.(string); ok {
Expand Down
70 changes: 70 additions & 0 deletions pkg/workflow/frontmatter_extraction_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
package workflow

import (
"os"
"path/filepath"
"testing"

"github.com/github/gh-aw/pkg/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -187,6 +190,73 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) {
})
}

func TestExtractAgentSandboxConfigMemory(t *testing.T) {
compiler := &Compiler{}

t.Run("extracts sandbox.agent.memory string", func(t *testing.T) {
agentObj := map[string]any{
"id": "awf",
"memory": "48g",
}

config := compiler.extractAgentSandboxConfig(agentObj)
require.NotNil(t, config, "Should extract agent sandbox config")
assert.Equal(t, "48g", config.Memory, "Should extract sandbox.agent.memory")
Comment on lines +202 to +204
})

t.Run("memory is empty when absent", func(t *testing.T) {
agentObj := map[string]any{
"id": "awf",
}

config := compiler.extractAgentSandboxConfig(agentObj)
require.NotNil(t, config, "Should extract agent sandbox config")
assert.Empty(t, config.Memory, "Memory should be empty when not configured")
})

t.Run("ignores non-string memory value", func(t *testing.T) {
agentObj := map[string]any{
"id": "awf",
"memory": 48,
}

config := compiler.extractAgentSandboxConfig(agentObj)
require.NotNil(t, config, "Should extract agent sandbox config")
assert.Empty(t, config.Memory, "Memory should be empty for non-string value")
})
}

func TestCompileWorkflowPassesSandboxAgentMemoryToAWF(t *testing.T) {
tmpDir := testutil.TempDir(t, "sandbox-agent-memory-*")
workflowPath := filepath.Join(tmpDir, "memory-limit.md")

workflowContent := `---
on: workflow_dispatch
permissions:
contents: read
engine: copilot
sandbox:
agent:
memory: 48g
---

# Memory limit
`

require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0o644))

compiler := NewCompiler()
require.NoError(t, compiler.CompileWorkflow(workflowPath))

lockPath := filepath.Join(tmpDir, "memory-limit.lock.yml")
lockContent, err := os.ReadFile(lockPath)
require.NoError(t, err, "expected compiled lock file to be generated")

lockYAML := string(lockContent)
assert.Contains(t, lockYAML, "--memory-limit", "compiled lock file should include --memory-limit flag")
assert.Regexp(t, `--memory-limit\s+48g`, lockYAML, "compiled lock file should pass --memory-limit 48g to AWF")
}

func TestExtractDefaultAiCreditsPricingFromModels(t *testing.T) {
t.Run("extracts zero pricing for self-hosted BYOK model", func(t *testing.T) {
frontmatter := map[string]any{
Expand Down
20 changes: 20 additions & 0 deletions pkg/workflow/sandbox_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ func validateSandboxConfig(workflowData *WorkflowData) error {
}
}

// Validate memory format if specified in agent config
if agentConfig != nil && agentConfig.Memory != "" {
if err := validateAgentMemoryLimit(agentConfig.Memory); err != nil {
return err
}
}

// Validate gVisor runtime compatibility
if agentConfig != nil && agentConfig.Runtime == AgentRuntimeGVisor {
// gVisor is incompatible with ARC/DinD topology: the runner has no access to the
Expand Down Expand Up @@ -445,6 +452,19 @@ func validateBoundedQueryMemoryLimit(memoryLimit string) error {
return nil
}

// validateAgentMemoryLimit checks that a sandbox.agent.memory string has the correct format.
func validateAgentMemoryLimit(memory string) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Go regex is stricter than — and inconsistent with — the published JSON schema for the same field, so identical values pass one gate and fail the other.

💡 Details

memoryLimitPattern here is ^[1-9][0-9]*[bkmgBKMG]$ (single-letter units, no leading zero, no zero value), but pkg/parser/schemas/main_workflow_schema.json for sandbox.agent.memory still uses ^[0-9]+(b|k|m|g|kb|mb|gb|B|K|M|G|KB|MB|GB)$, which accepts 48gb, 0m, 08g, and two-letter units (kb/mb/gb) that the Go validator rejects, and also accepts 0m/08g which Go rejects.

Result: editor/IDE JSON-schema validation (via the schema) will happily accept memory: 48gb or memory: 0m, but gh aw compile then rejects them with a Go-level error — a confusing split-brain UX. The docs' own "rejected" example (48gb) is actually schema-valid, compounding the confusion.

Fix: align the schema pattern with memoryLimitPattern, or relax the Go validator to accept the schema's broader set (kb/mb/gb suffixes), and update the docs example accordingly so all three sources of truth agree.

if !memoryLimitPattern.MatchString(memory) {
return NewValidationError(
"sandbox.agent.memory",
memory,
"memory value is not a valid limit. Expected a positive integer without leading zeros followed by a unit: b, k, m, or g (e.g. \"4g\", \"512m\")",
fmt.Sprintf("Use a valid memory limit format:\n\nsandbox:\n agent:\n memory: 4g # examples: 512m, 4g, 8g\n\nSee: %s", constants.DocsSandboxURL),
)
}
return nil
}

func getSandboxDisableJustification(workflowData *WorkflowData) (string, error) {
if workflowData == nil || workflowData.Features == nil {
return "", errors.New("dangerously-disable-sandbox-agent feature is missing")
Expand Down
77 changes: 77 additions & 0 deletions pkg/workflow/sandbox_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,80 @@ func TestValidateSandboxConfigStoresJustification(t *testing.T) {
assert.Equal(t, reason, workflowData.SandboxConfig.Agent.DisableReason,
"justification must be stored on AgentSandboxConfig for audit/logging")
}

func TestValidateAgentMemoryLimit(t *testing.T) {
tests := []struct {
name string
memory string
expectError bool
}{
{name: "valid: 48g", memory: "48g", expectError: false},
{name: "valid: 512m", memory: "512m", expectError: false},
{name: "valid: 8G uppercase", memory: "8G", expectError: false},
{name: "valid: 1024k", memory: "1024k", expectError: false},
{name: "invalid: no unit", memory: "48", expectError: true},
{name: "invalid: gb suffix", memory: "48gb", expectError: true},
{name: "invalid: leading zero", memory: "08g", expectError: true},
{name: "invalid: zero", memory: "0m", expectError: true},
{name: "invalid: empty", memory: "", expectError: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAgentMemoryLimit(tt.memory)
if tt.expectError {
require.Error(t, err, "expected validation error for memory %q", tt.memory)
} else {
require.NoError(t, err, "expected no error for memory %q", tt.memory)
}
})
}
}

func TestValidateSandboxConfigMemory(t *testing.T) {
t.Run("valid memory passes validation", func(t *testing.T) {
workflowData := &WorkflowData{
Tools: map[string]any{"github": map[string]any{"mode": "remote"}},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{Memory: "4g"},
},
}
err := validateSandboxConfig(workflowData)
assert.NoError(t, err, "valid memory should pass validation")
})

t.Run("invalid memory format fails validation", func(t *testing.T) {
workflowData := &WorkflowData{
Tools: map[string]any{"github": map[string]any{"mode": "remote"}},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{Memory: "48gb"},
},
}
err := validateSandboxConfig(workflowData)
require.Error(t, err, "invalid memory format should fail validation")
assert.Contains(t, err.Error(), "48gb")
})

t.Run("leading zero memory format explains why it is invalid", func(t *testing.T) {
workflowData := &WorkflowData{
Tools: map[string]any{"github": map[string]any{"mode": "remote"}},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{Memory: "08g"},
},
}
err := validateSandboxConfig(workflowData)
require.Error(t, err, "leading-zero memory format should fail validation")
assert.Contains(t, err.Error(), "without leading zeros")
})

t.Run("absent memory skips validation", func(t *testing.T) {
workflowData := &WorkflowData{
Tools: map[string]any{"github": map[string]any{"mode": "remote"}},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{},
},
}
err := validateSandboxConfig(workflowData)
assert.NoError(t, err, "absent memory should pass validation")
})
}
Loading