Skip to content
Closed
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,77 @@
# ADR-29431: Version-Gated Engine-Specific AWF Flags for OpenCode

**Date**: 2026-05-01
**Status**: Draft
**Deciders**: lpcox, copilot-swe-agent

---

## Part 1 — Narrative (Human-Friendly)

### Context

The `gh-aw` compiler translates workflow definitions into AWF (Agentic Workflow Firewall) CLI invocations. Different engines require different AWF flags and open network ports. The OpenCode engine introduces a dynamic provider routing proxy that listens on port 10004 and is activated by the AWF flag `--enable-opencode`. AWF itself did not support this flag before v0.25.30; passing an unrecognized flag to an older AWF version causes the run to fail at startup with an "unknown flag" error. An existing version-gating mechanism (`awfSupportsAllowHostPorts`) already handles the same problem for `--allow-host-ports`, establishing a pattern the codebase can follow.

### Decision

We will emit `--enable-opencode` and append port 10004 to `--allow-host-ports` in `BuildAWFArgs()` when (a) the workflow engine is `opencode` and (b) the effective AWF version is ≥ v0.25.30. Both conditions must be true simultaneously, enforced through a dedicated version-gate function `awfSupportsEnableOpenCode`. The `AWFEnableOpenCodeMinVersion` constant is introduced in `pkg/constants/version_constants.go` to make the threshold explicit and testable. Because `AWFEnableOpenCodeMinVersion` (v0.25.30) is greater than `AWFAllowHostPortsMinVersion` (v0.25.24), when the OpenCode flag is enabled, `--allow-host-ports` support is always already guaranteed.

### Alternatives Considered

#### Alternative 1: Unconditional flag emission (no version gate)

Emit `--enable-opencode` and port 10004 for every OpenCode workflow regardless of AWF version. This is simpler and eliminates the version-gate bookkeeping. It was rejected because OpenCode workflows that pin an older AWF image version (e.g., in a workflow's `firewall.version` field) would receive an unknown flag and fail immediately at AWF startup — a hard runtime error with no graceful fallback.

#### Alternative 2: Dedicated firewall config field for OpenCode ports

Add a first-class field (e.g., `firewall.opencodePorts`) to the workflow's sandbox configuration so operators can declare the port list explicitly rather than deriving it from the engine name. This was considered because it separates the network port decision from the engine-name string, making each workflow's intent explicit. It was rejected because no existing workflow uses such a field, the engine name is already the canonical signal of intent in this codebase, and adding a new config field would require schema changes and documentation with no benefit for the current use case.

### Consequences

#### Positive
- OpenCode workflows on supported AWF versions correctly activate the API proxy listener on port 10004, enabling dynamic provider routing.
- The solution follows the established version-gating pattern (`awfSupportsAllowHostPorts`), making the codebase consistent and the pattern easy to discover for future contributors.
- The `AWFEnableOpenCodeMinVersion` constant gives operators a single place to look up the minimum AWF version required for OpenCode support.

#### Negative
- Each new AWF feature flag now requires a minimum-version constant, a version-gate function, and associated tests. The pattern scales linearly with the number of features, adding maintenance surface.
- The `opencodeEnabled` pre-computation in `BuildAWFArgs` couples engine-name logic with firewall-version logic in one function, making that function responsible for more decisions than before.

#### Neutral
- The `"latest"` AWF version string is treated as always meeting the minimum version requirement, which is a conservative assumption already established by the existing pattern.
- Non-semver version strings (e.g., branch names used in development) are treated as too old (returns false), erring on the side of not emitting the flag.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Flag Emission

1. Implementations **MUST** emit `--enable-opencode` in the AWF argument list when the workflow engine is `opencode` and the effective AWF version is greater than or equal to `AWFEnableOpenCodeMinVersion` (v0.25.30).
2. Implementations **MUST NOT** emit `--enable-opencode` when the workflow engine is not `opencode`.
3. Implementations **MUST NOT** emit `--enable-opencode` when the effective AWF version is less than `AWFEnableOpenCodeMinVersion`, even if the engine is `opencode`.
4. Implementations **MUST** treat the string `"latest"` as satisfying the minimum version requirement for `--enable-opencode`.
5. Implementations **MUST** treat non-semver version strings (e.g., branch names) as not satisfying the minimum version requirement (conservative default).

### Port Allowance

1. Implementations **MUST** append port 10004 to the `--allow-host-ports` argument if and only if `--enable-opencode` would also be emitted (same condition: engine is `opencode` and AWF version ≥ v0.25.30).
2. Implementations **MUST NOT** include port 10004 in `--allow-host-ports` for any engine other than `opencode`.
3. Implementations **MUST NOT** include port 10004 in `--allow-host-ports` for `opencode` workflows using an AWF version below `AWFEnableOpenCodeMinVersion`.
4. Implementations **SHOULD** compute the `opencodeEnabled` boolean once (pre-computation) rather than repeating the engine-name and version checks at each emission site within `BuildAWFArgs`.

### Version Constant Management

1. Implementations **MUST** define the minimum AWF version threshold for `--enable-opencode` as the named constant `AWFEnableOpenCodeMinVersion` in `pkg/constants/version_constants.go`.
2. Implementations **MUST NOT** embed the version string `"v0.25.30"` as a magic literal at call sites; the named constant **MUST** be used instead.
3. Implementations **SHOULD** document the reason the minimum version was introduced (the AWF PR or issue that added the flag) in the constant's comment.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25201347429) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
7 changes: 7 additions & 0 deletions pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ const AWFCliProxyMinVersion Version = "v0.25.17"
// --allow-host-ports or the run will fail at startup with an unknown flag error.
const AWFAllowHostPortsMinVersion Version = "v0.25.24"

// AWFEnableOpenCodeMinVersion is the minimum AWF version that supports the
// --enable-opencode flag. This flag enables the OpenCode API proxy listener
// on port 10004 (dynamic provider routing). Workflows pinning an older AWF
// version must not emit --enable-opencode or the run will fail at startup.
// Introduced in gh-aw-firewall PR #2337.
const AWFEnableOpenCodeMinVersion Version = "v0.25.30"
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.

Good use of a named constant for the version threshold. One suggestion: consider adding a link to the specific AWF release notes or changelog entry (not just the PR number) so future maintainers can quickly verify what changed in that version without needing access to the internal firewall repo.


// CopilotNoAskUserMinVersion is the minimum Copilot CLI version that supports the --no-ask-user
// flag, which enables fully autonomous agentic runs by suppressing interactive prompts.
// Workflows using an older Copilot CLI version must not emit --no-ask-user or the run will fail.
Expand Down
20 changes: 18 additions & 2 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
// },
// "apiProxy": {
// "enabled": true,
// "enableOpenCode": true,
// "targets": {
// "openai": { "host": "api.openai.com" },
// "anthropic": { "host": "api.anthropic.com" },
Expand Down Expand Up @@ -47,6 +48,8 @@ import (
"encoding/json"
"fmt"
"strings"

"github.com/github/gh-aw/pkg/constants"
)

// AWFConfigFile represents the AWF configuration file schema.
Expand Down Expand Up @@ -79,12 +82,18 @@ type AWFNetworkConfig struct {
}

// AWFAPIProxyConfig is the "apiProxy" section of the AWF config file.
// It maps to the --enable-api-proxy and --*-api-target CLI flags.
// It maps to the --enable-api-proxy, --enable-opencode, and --*-api-target CLI flags.
type AWFAPIProxyConfig struct {
// Enabled enables the API proxy sidecar for LLM gateway credential isolation.
// Maps to: --enable-api-proxy
Enabled bool `json:"enabled"`

// EnableOpenCode enables the OpenCode API proxy listener on port 10004
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 omitempty tag is correct here — this avoids emitting "enableOpenCode":false in the JSON config for non-opencode engines. Worth adding a note in the comment that this field is intentionally omitted (not just false) when opencode is not active, to avoid confusion for anyone reading the generated AWF config files.

// (dynamic provider routing). Only emitted when true (omitempty).
// Maps to: --enable-opencode
// Requires: Enabled == true and AWF >= v0.25.30
EnableOpenCode bool `json:"enableOpenCode,omitempty"`

// Targets holds per-provider API target overrides.
// Supported keys: "openai", "anthropic", "copilot", "gemini"
Targets map[string]*AWFAPITargetConfig `json:"targets,omitempty"`
Expand Down Expand Up @@ -137,6 +146,14 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
Enabled: true,
}

// Enable the OpenCode API proxy listener on port 10004 for the opencode engine.
// Expressed in the config file as apiProxy.enableOpenCode so it is auditable
// alongside the other apiProxy settings, mirroring the --enable-opencode CLI flag.
firewallConfig := getFirewallConfig(config.WorkflowData)
if config.EngineName == string(constants.OpenCodeEngine) && awfSupportsEnableOpenCode(firewallConfig) {
apiProxy.EnableOpenCode = true
}

targets := map[string]*AWFAPITargetConfig{}

if openaiTarget := extractAPITargetHost(config.WorkflowData, "OPENAI_BASE_URL"); openaiTarget != "" {
Expand All @@ -158,7 +175,6 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
awfConfig.APIProxy = apiProxy

// ── Container section ─────────────────────────────────────────────────────
firewallConfig := getFirewallConfig(config.WorkflowData)
awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData)
if awfImageTag != "" {
awfConfig.Container = &AWFContainerConfig{
Expand Down
65 changes: 65 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,71 @@ func TestBuildAWFConfigJSON(t *testing.T) {
assert.NotContains(t, jsonStr, "\n", "JSON output should not contain newlines (must be compact)")
assert.NotContains(t, jsonStr, " ", "JSON output should not contain indentation")
})

t.Run("enableOpenCode is set in apiProxy for opencode engine with supported AWF version", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "opencode",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "opencode"},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: "v0.25.30",
},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.Contains(t, jsonStr, `"enableOpenCode":true`, "apiProxy should include enableOpenCode:true for opencode engine")
})

t.Run("enableOpenCode is not set for non-opencode engines", func(t *testing.T) {
for _, engine := range []string{"copilot", "claude", "codex", "gemini"} {
config := AWFCommandConfig{
EngineName: engine,
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: engine},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: "v0.25.30",
},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.NotContains(t, jsonStr, `"enableOpenCode"`, "apiProxy should not include enableOpenCode for engine %s", engine)
}
})

t.Run("enableOpenCode is not set for opencode engine with old AWF version", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "opencode",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "opencode"},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: "v0.25.29",
},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.NotContains(t, jsonStr, `"enableOpenCode"`, "apiProxy should not include enableOpenCode for AWF version below minimum")
})
}

// TestBuildAWFConfigJSON_DomainDeduplication verifies that duplicate domain entries
Expand Down
47 changes: 47 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ func BuildAWFCommand(config AWFCommandConfig) string {
// BuildAWFCommand and are therefore not emitted here:
// - --allow-domains / --block-domains → network.allowDomains / network.blockDomains
// - --enable-api-proxy → apiProxy.enabled
// - --enable-opencode (opencode engine) → apiProxy.enableOpenCode (AWF v0.25.30+)
// - --image-tag → container.imageTag
// - --openai-api-target → apiProxy.targets.openai.host
// - --anthropic-api-target → apiProxy.targets.anthropic.host
Expand Down Expand Up @@ -316,19 +317,34 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
// AWF's --enable-host-access defaults to ports 80,443. The MCP gateway now
// listens on port 8080 (non-privileged), so we must explicitly allow it
// when AWF supports --allow-host-ports.
// For OpenCode engine, also include port 10004 (OpenCode dynamic provider routing)
// and emit --enable-opencode when AWF supports it (v0.25.30+).
// AWFEnableOpenCodeMinVersion > AWFAllowHostPortsMinVersion, so when opencode support
// is available, --allow-host-ports support is also guaranteed.
opencodeEnabled := config.EngineName == string(constants.OpenCodeEngine) && awfSupportsEnableOpenCode(firewallConfig)
if awfSupportsAllowHostPorts(firewallConfig) {
mcpGatewayPort := int(DefaultMCPGatewayPort)
if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil &&
config.WorkflowData.SandboxConfig.MCP != nil && config.WorkflowData.SandboxConfig.MCP.Port > 0 {
mcpGatewayPort = config.WorkflowData.SandboxConfig.MCP.Port
}
hostPorts := fmt.Sprintf("80,443,%d", mcpGatewayPort)
if opencodeEnabled {
hostPorts += ",10004"
}
awfArgs = append(awfArgs, "--allow-host-ports", hostPorts)
awfHelpersLog.Printf("Added --allow-host-ports %s for MCP gateway access", hostPorts)
} else {
awfHelpersLog.Printf("Skipping --allow-host-ports: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAllowHostPortsMinVersion)
}

if opencodeEnabled {
awfArgs = append(awfArgs, "--enable-opencode")
awfHelpersLog.Print("Added --enable-opencode and port 10004 for OpenCode API proxy listener")
} else if config.EngineName == string(constants.OpenCodeEngine) {
awfHelpersLog.Printf("Skipping --enable-opencode: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFEnableOpenCodeMinVersion)
}

// Skip pulling images since they are pre-downloaded
awfArgs = append(awfArgs, "--skip-pull")
awfHelpersLog.Print("Using --skip-pull since images are pre-downloaded")
Expand Down Expand Up @@ -684,3 +700,34 @@ func awfSupportsAllowHostPorts(firewallConfig *FirewallConfig) bool {
minVersion := string(constants.AWFAllowHostPortsMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}

// awfSupportsEnableOpenCode returns true when the effective AWF version supports
// --enable-opencode.
//
// The --enable-opencode flag enables the OpenCode API proxy listener on port 10004
// (dynamic provider routing). It was introduced in AWF v0.25.30 (gh-aw-firewall PR #2337).
// Any workflow that pins an explicit version older than v0.25.30 must not emit
// --enable-opencode or the run will fail at startup.
//
// Special cases:
// - No version override (firewallConfig is nil or has no Version): use DefaultFirewallVersion
// and compare against AWFEnableOpenCodeMinVersion.
// - "latest": always returns true (latest is always a new release).
// - Any semver string ≥ AWFEnableOpenCodeMinVersion: returns true.
// - Any semver string < AWFEnableOpenCodeMinVersion: returns false.
// - Non-semver string (e.g. a branch name): returns false (conservative).
func awfSupportsEnableOpenCode(firewallConfig *FirewallConfig) bool {
var versionStr string
if firewallConfig != nil && firewallConfig.Version != "" {
versionStr = firewallConfig.Version
} else {
versionStr = string(constants.DefaultFirewallVersion)
}

if strings.EqualFold(versionStr, "latest") {
return true
}

minVersion := string(constants.AWFEnableOpenCodeMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}
Loading