Skip to content
5 changes: 5 additions & 0 deletions pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ 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"

// AWFDockerHostPathPrefixMinVersion is the minimum AWF version that supports the
// --docker-host-path-prefix flag used for ARC/DinD split runner/daemon filesystems.
// Workflows pinning an older AWF version must not emit this flag.
const AWFDockerHostPathPrefixMinVersion Version = "v0.25.43"

// 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
65 changes: 61 additions & 4 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ import (

var awfHelpersLog = logger.New("workflow:awf_helpers")

const (
awfArcDindPrefixArgsVarName = "GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS"
// Bash regex used in [[ ... =~ ... ]] to detect localhost TCP Docker hosts.
// Keep this in bash-compatible syntax (escaped dots) because it is emitted directly
// into generated shell scripts.
awfArcDindDockerHostRegex = `^tcp://(localhost|127\.0\.0\.1)(:[0-9]+)?$`
awfArcDindHostPathPrefixFlag = "--docker-host-path-prefix /tmp/gh-aw"
)

// AWFCommandConfig contains configuration for building AWF commands.
// This struct centralizes all the parameters needed to construct an AWF-wrapped command.
type AWFCommandConfig struct {
Expand Down Expand Up @@ -88,6 +97,24 @@ func BuildAWFCommand(config AWFCommandConfig) string {
// and --mount "${RUNNER_TEMP}/...") are appended raw below so that shell variable
// expansion is not suppressed by single-quoting.
awfArgs := BuildAWFArgs(config)
firewallConfig := getFirewallConfig(config.WorkflowData)

// Auto-detect ARC/DinD split daemon topology at runtime and emit
// --docker-host-path-prefix when supported by the selected AWF version.
// This avoids requiring workflow-authored sandbox.agent.args for standard ARC DinD setups.
arcDindPrefixProbe := ""
arcDindPrefixArgsRef := ""
if awfSupportsDockerHostPathPrefix(firewallConfig) {
arcDindPrefixProbe = fmt.Sprintf(`%s=""
if [[ "${DOCKER_HOST:-}" =~ %s ]]; then
%s="%s"

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.

[/tdd] When awfSupportsDockerHostPathPrefix returns false, arcDindPrefixProbe is set to "" and arcDindPrefixArgsRef is also "". Each format string has %s\n%s\n — two consecutive %s slots that will both expand to empty strings, producing a stray blank line in the generated script on older AWF versions.

A test that captures the full command output for the old-version path and asserts there are no consecutive blank lines (or that the probe variable name does not appear at all) would pin this behavior explicitly and catch any future regression.

fi`,
awfArcDindPrefixArgsVarName,
awfArcDindDockerHostRegex,
awfArcDindPrefixArgsVarName,
awfArcDindHostPathPrefixFlag)
arcDindPrefixArgsRef = fmt.Sprintf("${%s}", awfArcDindPrefixArgsVarName)
}
Comment on lines +115 to +117

// Build the expandable args string for args that need shell variable expansion.
// These MUST be appended as raw (unescaped) strings because single-quoting would

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.

[/zoom-out] The arcDindPrefixProbe injection is copy-pasted into all four fmt.Sprintf branches (lines ~193, ~212, ~229, ~245). These branches already differ only in which of pathSetup / preCreateLog / configFileSetup they include — this change makes each branch one line longer in two places, amplifying the existing duplication.

Consider extracting the command assembly into a small helper that accepts the optional preamble lines as a slice and joins them, so the ARC/DinD probe (and any future injections) only needs to be added once:

func assemblePipefailCommand(preambles []string, probe, awfCmd, expandableArgs, arcDindRef, awfArgs, wrapped, logFile string) string {
    body := strings.Join(append(preambles, probe), "\n")
    return fmt.Sprintf("set -o pipefail\n%s\n# shellcheck disable=SC1003\n%s %s %s %s \\\n  -- %s 2>&1 | tee -a %s", body, awfCmd, expandableArgs, arcDindRef, awfArgs, wrapped, logFile)
}

This would make future version-gated injections trivial and keep the logic in one place.

Expand Down Expand Up @@ -166,14 +193,17 @@ func BuildAWFCommand(config AWFCommandConfig) string {
%s
%s
%s
%s
# shellcheck disable=SC1003
%s %s %s \
%s %s %s %s \
-- %s 2>&1 | tee -a %s`,
config.PathSetup,
preCreateLog,
configFileSetup,
arcDindPrefixProbe,
awfCommand,
expandableArgs,
arcDindPrefixArgsRef,
shellJoinArgs(awfArgs),
shellWrappedCommand,
shellEscapeArg(config.LogFile))
Expand All @@ -182,39 +212,48 @@ func BuildAWFCommand(config AWFCommandConfig) string {
command = fmt.Sprintf(`set -o pipefail
%s
%s
%s
# shellcheck disable=SC1003
%s %s %s \
%s %s %s %s \
-- %s 2>&1 | tee -a %s`,
config.PathSetup,
preCreateLog,
arcDindPrefixProbe,
awfCommand,
expandableArgs,
arcDindPrefixArgsRef,
shellJoinArgs(awfArgs),
shellWrappedCommand,
shellEscapeArg(config.LogFile))
} else if configFileSetup != "" {
command = fmt.Sprintf(`set -o pipefail
%s
%s
%s
# shellcheck disable=SC1003
%s %s %s \
%s %s %s %s \
-- %s 2>&1 | tee -a %s`,
preCreateLog,
configFileSetup,
arcDindPrefixProbe,
awfCommand,
expandableArgs,
arcDindPrefixArgsRef,
shellJoinArgs(awfArgs),
shellWrappedCommand,
shellEscapeArg(config.LogFile))
} else {
command = fmt.Sprintf(`set -o pipefail
%s
%s
# shellcheck disable=SC1003
%s %s %s \
%s %s %s %s \
-- %s 2>&1 | tee -a %s`,
preCreateLog,
arcDindPrefixProbe,
awfCommand,
expandableArgs,
arcDindPrefixArgsRef,
shellJoinArgs(awfArgs),
shellWrappedCommand,
shellEscapeArg(config.LogFile))
Expand Down Expand Up @@ -684,3 +723,21 @@ func awfSupportsAllowHostPorts(firewallConfig *FirewallConfig) bool {
minVersion := string(constants.AWFAllowHostPortsMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}

// awfSupportsDockerHostPathPrefix returns true when the effective AWF version supports
// --docker-host-path-prefix.
func awfSupportsDockerHostPathPrefix(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.AWFDockerHostPathPrefixMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}
42 changes: 42 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,48 @@ func TestAWFSupportsAllowHostPorts(t *testing.T) {
}
}

// TestAWFSupportsDockerHostPathPrefix tests the awfSupportsDockerHostPathPrefix version gate.
func TestAWFSupportsDockerHostPathPrefix(t *testing.T) {
tests := []struct {
name string
firewallConfig *FirewallConfig
want bool
}{
{
name: "nil firewall config returns true (uses default version)",
firewallConfig: nil,
want: true,
},
{
name: "empty version returns true (uses default version)",
firewallConfig: &FirewallConfig{},
want: true,
},
{
name: "latest returns true",
firewallConfig: &FirewallConfig{Version: "latest"},
want: true,
},
{
name: "v0.25.43 supports --docker-host-path-prefix (exact minimum version)",
firewallConfig: &FirewallConfig{Version: "v0.25.43"},
want: true,
},
{
name: "v0.25.42 does not support --docker-host-path-prefix",
firewallConfig: &FirewallConfig{Version: "v0.25.42"},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := awfSupportsDockerHostPathPrefix(tt.firewallConfig)
assert.Equal(t, tt.want, got, "awfSupportsDockerHostPathPrefix result")
})
}
}

// TestGetGeminiAPITarget tests the GetGeminiAPITarget helper that resolves the effective
// Gemini API target from GEMINI_API_BASE_URL in engine.env or the default endpoint.
func TestGetGeminiAPITarget(t *testing.T) {
Expand Down
34 changes: 34 additions & 0 deletions pkg/workflow/firewall_args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ func TestFirewallArgsInCopilotEngine(t *testing.T) {
t.Error("Expected command to contain '--log-level'")
}

initSnippet := `GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""`
conditionSnippet := `if [[ "${DOCKER_HOST:-}" =~ ^tcp://(localhost|127\.0\.0\.1)(:[0-9]+)?$ ]]; then`

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.

[/tdd] The conditionSnippet string duplicates the regex literal from the awfArcDindDockerHostRegex package-level constant in awf_helpers.go. If the regex ever changes (e.g., to add IPv6 support), this test will pass with the old string while the real probe uses the new one.

Reference the constant directly so the test always stays in sync:

conditionSnippet := fmt.Sprintf(`if [[ "${DOCKER_HOST:-}" =~ %s ]]; then`, awfArcDindDockerHostRegex)

flagAssignmentSnippet := `GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"`

initIdx := strings.Index(stepContent, initSnippet)
conditionIdx := strings.Index(stepContent, conditionSnippet)
flagIdx := strings.Index(stepContent, flagAssignmentSnippet)
if initIdx == -1 || conditionIdx == -1 || flagIdx == -1 || !(initIdx < conditionIdx && conditionIdx < flagIdx) {
t.Error("Expected command to initialize ARC/DinD probe variable, then evaluate DOCKER_HOST condition, then assign docker-host-path-prefix flag")
}

// Verify that --log-dir is included in copilot args for log collection
if !strings.Contains(stepContent, "--log-dir /tmp/gh-aw/sandbox/agent/logs/") {
t.Error("Expected copilot command to contain '--log-dir /tmp/gh-aw/sandbox/agent/logs/' for log collection in firewall mode")
Expand Down Expand Up @@ -211,6 +222,29 @@ func TestFirewallArgsInCopilotEngine(t *testing.T) {
}
})

t.Run("skips docker-host-path-prefix probe when AWF version is too old", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "copilot",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: "v0.25.42",
},
},
}

engine := NewCopilotEngine()
steps := engine.GetExecutionSteps(workflowData, "test.log")
stepContent := requireCopilotExecutionStep(t, steps)

if strings.Contains(stepContent, "--docker-host-path-prefix /tmp/gh-aw") {
t.Error("Expected command to skip --docker-host-path-prefix for unsupported AWF versions")

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.

[/tdd] The "skips docker-host-path-prefix probe" test only asserts that --docker-host-path-prefix is absent from the generated command. It doesn't assert that the probe variable initialisation (GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="") is also absent.

On an old AWF version the probe block should be entirely omitted — not just the flag value. Adding:

if strings.Contains(stepContent, awfArcDindPrefixArgsVarName) {
    t.Error("Expected no probe variable for unsupported AWF versions")
}

would give the test full coverage of the negative path and guard against accidental partial injection.

}
})

t.Run("AWF command includes ssl-bump flag when enabled", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
Expand Down
Loading