Summary
The MCP gateway is launched as a non-root container (--user <uid>:<gid>) that reads the Docker socket via --group-add <gid>, where <gid> is computed by stat-ing a socket path on the launcher (runner) host. For any non-unix DOCKER_HOST (tcp://, ssh://, npipe://) the path falls back to /var/run/docker.sock. On split-daemon / remote-daemon topologies — notably ARC runners with a Docker-in-Docker sidecar, where DOCKER_HOST=tcp://… and the daemon's unix socket lives in the sidecar, not on the runner — that path doesn't exist on the launcher host. The stat fails, --group-add silently falls back to 0, and the non-root gateway cannot access the socket:
[ERROR] Docker daemon is not accessible
Operators currently have to carry a socket-symlink + chown workaround in their runner/pod spec to satisfy this. This issue proposes making gh-aw resolve the socket group robustly (and fail loudly), so no host-side hack is needed.
Environment
- gh-aw v0.82.4 (behavior present since ~v0.80.9 / mcpg v0.3.27)
- Runner:
actions-runner-controller gha-runner-scale-set with a privileged dind sidecar; runner: topology: arc-dind
DOCKER_HOST=tcp://127.0.0.1:2375 on the runner (daemon in the sidecar); the daemon's unix socket is exposed to the runner via a shared volume at a non-default path, group-owned by the docker group
- Any engine using the MCP gateway (
github / safeoutputs MCP)
Symptom
The gateway container starts as --user 1001:1001 --group-add 0 and cannot read the bind-mounted socket → Docker daemon is not accessible, and MCP tools never come up. On our setup this also intersects with the tcp-only daemon (there is no /var/run/docker.sock on the runner at all).
Root cause (source analysis — pkg/workflow/mcp_setup_generator.go)
generateMCPGatewaySetup emits this shell into the "Start MCP Gateway" step:
MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0')
MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0')
case "${DOCKER_HOST:-}" in
unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;;
/* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;;
* ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; # tcp://, ssh://, npipe:// all land here
esac
DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0')
buildMCPGatewayContainerCommand then emits:
--user ${MCP_GATEWAY_UID}:${MCP_GATEWAY_GID}
--group-add ${DOCKER_SOCK_GID}
-v ${DOCKER_SOCK_PATH}:/var/run/docker.sock
and appendMCPGatewayBaseEnvFlags forces:
-e DOCKER_HOST=unix:///var/run/docker.sock
Three problems compound on a split/remote daemon:
- Wrong socket path for non-unix
DOCKER_HOST. With DOCKER_HOST=tcp://…, DOCKER_SOCK_PATH falls back to /var/run/docker.sock, a launcher-host path that need not exist (the daemon is remote / in a sidecar). Both the stat (for the GID) and the -v …:/var/run/docker.sock bind-mount then target a nonexistent path (Docker silently creates an empty directory at the mount source).
- Silent fallback to group 0.
stat … 2>/dev/null || echo '0' yields DOCKER_SOCK_GID=0 on failure. Combined with --user <uid>:<gid> (non-root), the gateway is added only to group 0 (root) and cannot read a socket owned by the docker group. The failure surfaces later as a misleading Docker daemon is not accessible rather than "couldn't determine the Docker socket group."
stat (no -L) reads the path's own group. Even where operators expose the socket via a symlink at /var/run/docker.sock, stat -c '%g' (without -L) reports the symlink's own group, not the resolved socket's — so simply symlinking is insufficient.
The workaround operators must carry today
To satisfy the generated logic, the runner/pod spec has to do:
# make the stat'd path exist and resolve (for the -v bind-mount) to the real sidecar socket
sudo ln -sf /dind-sock/docker.sock /var/run/docker.sock
# give the symlink ITSELF the docker gid (123) so `stat -c %g` (no -L) returns 123, not 0
sudo chown -h root:docker /var/run/docker.sock
This is fragile: it hard-codes the fallback path /var/run/docker.sock, depends on stat reading the link's own gid (hence chown -h), and assumes the docker group id (123). It should not be necessary.
Reproduction
- Run the agent (or any MCP-using workflow) on a runner where
DOCKER_HOST=tcp://… and there is no /var/run/docker.sock on the launcher host (e.g. ARC + dind sidecar).
- Observe the gateway start with
--group-add 0 and fail with Docker daemon is not accessible; MCP tools never initialize.
Proposed fix + implementation plan
All changes in pkg/workflow/mcp_setup_generator.go unless noted.
-
Honor explicit overrides (recommended, minimal). Before the DOCKER_HOST-derived guess, respect operator-provided values for the socket path and group — e.g. environment variables GH_AW_DOCKER_SOCK_PATH and GH_AW_DOCKER_SOCK_GID (or fields on the runner/awf config):
DOCKER_SOCK_PATH="${GH_AW_DOCKER_SOCK_PATH:-$derived_path}"
DOCKER_SOCK_GID="${GH_AW_DOCKER_SOCK_GID:-$(stat -Lc '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '')}"
This lets split-daemon operators point the gateway at the real socket + group directly, removing the symlink/chown hack entirely.
-
Follow symlinks when stat-ing — use stat -Lc '%g' so a symlinked socket resolves to the real socket's group (drops the need for chown -h).
-
Fail loudly instead of || echo '0'. When the socket group cannot be resolved and no override is given, error with an actionable message (name the path tried and the override env var) rather than proceeding with --group-add 0, which guarantees a confusing downstream Docker daemon is not accessible.
-
(Ideal, larger) Talk to the daemon directly when DOCKER_HOST is tcp://. When the daemon is reachable over TCP (or runner.topology: arc-dind, which gh-aw already knows), pass the original tcp:// DOCKER_HOST into the gateway container and skip the unix-socket bind-mount, the --group-add, and the -e DOCKER_HOST=unix://… override (buildMCPGatewayContainerCommand / appendMCPGatewayBaseEnvFlags). This removes the socket-group problem for the entire class of tcp/split-daemon topologies. Note the gateway runs in a bridge network, so reaching the host's tcp endpoint needs host.docker.internal:host-gateway (already added for other cases) or equivalent — gate this path so unix-socket setups are unchanged.
-
Tests (pkg/workflow/mcp_setup_generator_test.go): assert that with DOCKER_HOST=tcp://… (and/or runner.topology: arc-dind) the generated gateway command either honors the override / resolves a non-zero group, or (for option 4) connects over tcp with no --group-add; and that an unresolved group fails the step rather than emitting --group-add 0.
-
Docs: document the override(s) and the ARC/DinD / remote-daemon guidance in the MCP gateway / runner-topology reference.
-
Run make agent-finish (build, test, recompile, format, lint) before completing.
Summary
The MCP gateway is launched as a non-root container (
--user <uid>:<gid>) that reads the Docker socket via--group-add <gid>, where<gid>is computed bystat-ing a socket path on the launcher (runner) host. For any non-unixDOCKER_HOST(tcp://,ssh://,npipe://) the path falls back to/var/run/docker.sock. On split-daemon / remote-daemon topologies — notably ARC runners with a Docker-in-Docker sidecar, whereDOCKER_HOST=tcp://…and the daemon's unix socket lives in the sidecar, not on the runner — that path doesn't exist on the launcher host. Thestatfails,--group-addsilently falls back to0, and the non-root gateway cannot access the socket:Operators currently have to carry a socket-symlink +
chownworkaround in their runner/pod spec to satisfy this. This issue proposes making gh-aw resolve the socket group robustly (and fail loudly), so no host-side hack is needed.Environment
actions-runner-controllergha-runner-scale-setwith a privileged dind sidecar;runner: topology: arc-dindDOCKER_HOST=tcp://127.0.0.1:2375on the runner (daemon in the sidecar); the daemon's unix socket is exposed to the runner via a shared volume at a non-default path, group-owned by thedockergroupgithub/safeoutputsMCP)Symptom
The gateway container starts as
--user 1001:1001 --group-add 0and cannot read the bind-mounted socket →Docker daemon is not accessible, and MCP tools never come up. On our setup this also intersects with the tcp-only daemon (there is no/var/run/docker.sockon the runner at all).Root cause (source analysis —
pkg/workflow/mcp_setup_generator.go)generateMCPGatewaySetupemits this shell into the "Start MCP Gateway" step:buildMCPGatewayContainerCommandthen emits:and
appendMCPGatewayBaseEnvFlagsforces:Three problems compound on a split/remote daemon:
DOCKER_HOST. WithDOCKER_HOST=tcp://…,DOCKER_SOCK_PATHfalls back to/var/run/docker.sock, a launcher-host path that need not exist (the daemon is remote / in a sidecar). Both thestat(for the GID) and the-v …:/var/run/docker.sockbind-mount then target a nonexistent path (Docker silently creates an empty directory at the mount source).stat … 2>/dev/null || echo '0'yieldsDOCKER_SOCK_GID=0on failure. Combined with--user <uid>:<gid>(non-root), the gateway is added only to group0(root) and cannot read a socket owned by thedockergroup. The failure surfaces later as a misleadingDocker daemon is not accessiblerather than "couldn't determine the Docker socket group."stat(no-L) reads the path's own group. Even where operators expose the socket via a symlink at/var/run/docker.sock,stat -c '%g'(without-L) reports the symlink's own group, not the resolved socket's — so simply symlinking is insufficient.The workaround operators must carry today
To satisfy the generated logic, the runner/pod spec has to do:
This is fragile: it hard-codes the fallback path
/var/run/docker.sock, depends onstatreading the link's own gid (hencechown -h), and assumes the docker group id (123). It should not be necessary.Reproduction
DOCKER_HOST=tcp://…and there is no/var/run/docker.sockon the launcher host (e.g. ARC + dind sidecar).--group-add 0and fail withDocker daemon is not accessible; MCP tools never initialize.Proposed fix + implementation plan
All changes in
pkg/workflow/mcp_setup_generator.gounless noted.Honor explicit overrides (recommended, minimal). Before the
DOCKER_HOST-derived guess, respect operator-provided values for the socket path and group — e.g. environment variablesGH_AW_DOCKER_SOCK_PATHandGH_AW_DOCKER_SOCK_GID(or fields on the runner/awfconfig):This lets split-daemon operators point the gateway at the real socket + group directly, removing the symlink/
chownhack entirely.Follow symlinks when stat-ing — use
stat -Lc '%g'so a symlinked socket resolves to the real socket's group (drops the need forchown -h).Fail loudly instead of
|| echo '0'. When the socket group cannot be resolved and no override is given, error with an actionable message (name the path tried and the override env var) rather than proceeding with--group-add 0, which guarantees a confusing downstreamDocker daemon is not accessible.(Ideal, larger) Talk to the daemon directly when
DOCKER_HOSTistcp://. When the daemon is reachable over TCP (orrunner.topology: arc-dind, which gh-aw already knows), pass the originaltcp://DOCKER_HOSTinto the gateway container and skip the unix-socket bind-mount, the--group-add, and the-e DOCKER_HOST=unix://…override (buildMCPGatewayContainerCommand/appendMCPGatewayBaseEnvFlags). This removes the socket-group problem for the entire class of tcp/split-daemon topologies. Note the gateway runs in a bridge network, so reaching the host's tcp endpoint needshost.docker.internal:host-gateway(already added for other cases) or equivalent — gate this path so unix-socket setups are unchanged.Tests (
pkg/workflow/mcp_setup_generator_test.go): assert that withDOCKER_HOST=tcp://…(and/orrunner.topology: arc-dind) the generated gateway command either honors the override / resolves a non-zero group, or (for option 4) connects over tcp with no--group-add; and that an unresolved group fails the step rather than emitting--group-add 0.Docs: document the override(s) and the ARC/DinD / remote-daemon guidance in the MCP gateway / runner-topology reference.
Run
make agent-finish(build, test, recompile, format, lint) before completing.