From d63a7d4cedc9012eb6885d889d2883f0024b9661 Mon Sep 17 00:00:00 2001
From: Luan Vu
Date: Wed, 25 Mar 2026 08:19:52 +0700
Subject: [PATCH] fix(docker): auto-sync host Claude CLI credentials and show
Docker-aware login instructions (#398)
When running in Docker, the Claude CLI provider setup showed `claude auth login`
which doesn't work inside a container. This change:
- Mounts host ~/.claude as read-only into the container
- Entrypoint syncs credentials to a writable volume (respects cap_drop: ALL)
- Backend detects Docker via /.dockerenv and returns `in_docker` in auth-status API
- UI shows `docker compose exec goclaw claude auth login` for Docker deployments
Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
---
Dockerfile | 6 ++++--
docker-compose.yml | 1 +
docker-entrypoint.sh | 14 ++++++++++++++
internal/http/provider_verify.go | 6 ++++++
.../src/pages/providers/provider-cli-section.tsx | 9 +++++++--
5 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index c3cdef4e..54f40443 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -103,15 +103,17 @@ RUN chmod +x /app/docker-entrypoint.sh && \
# Create data directories.
# .runtime has split ownership: root owns the dir (so pkg-helper can write apk-packages),
# while pip/npm subdirs are goclaw-owned (runtime installs by the app process).
+# Symlink .claude → data volume so Claude CLI credentials persist across container recreates.
RUN mkdir -p /app/workspace /app/data/.runtime/pip /app/data/.runtime/npm-global/lib \
- /app/data/.runtime/pip-cache /app/skills /app/tsnet-state /app/.goclaw \
+ /app/data/.runtime/pip-cache /app/data/.claude /app/skills /app/tsnet-state /app/.goclaw \
+ && ln -s /app/data/.claude /app/.claude \
&& touch /app/data/.runtime/apk-packages \
&& chown -R goclaw:goclaw /app/workspace /app/skills /app/tsnet-state /app/.goclaw \
&& chown goclaw:goclaw /app/bundled-skills /app/data \
&& chown root:goclaw /app/data/.runtime /app/data/.runtime/apk-packages \
&& chmod 0750 /app/data/.runtime \
&& chmod 0640 /app/data/.runtime/apk-packages \
- && chown -R goclaw:goclaw /app/data/.runtime/pip /app/data/.runtime/npm-global /app/data/.runtime/pip-cache
+ && chown -R goclaw:goclaw /app/data/.runtime/pip /app/data/.runtime/npm-global /app/data/.runtime/pip-cache /app/data/.claude
# Default environment
ENV GOCLAW_CONFIG=/app/config.json \
diff --git a/docker-compose.yml b/docker-compose.yml
index 04428b67..1ee7f3f1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -40,6 +40,7 @@ services:
volumes:
- goclaw-data:/app/data
- goclaw-workspace:/app/workspace
+ - ${HOME}/.claude:/app/.claude-host:ro
security_opt:
- no-new-privileges:true
init: true
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 5bf0bcd4..2d351407 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -72,6 +72,20 @@ if [ -x /app/pkg-helper ] && [ "$(id -u)" = "0" ]; then
fi
fi
+# Copy Claude CLI credentials from root-owned read-only mount to goclaw-accessible location.
+# /app/.claude is a symlink → /app/data/.claude (writable volume, see Dockerfile).
+# Root lacks CAP_DAC_OVERRIDE (cap_drop: ALL), so use /tmp as staging area:
+# root reads the source (root-owned 600) → /tmp, then goclaw copies to data volume.
+if [ -f /app/.claude-host/.credentials.json ]; then
+ (cp /app/.claude-host/.credentials.json /tmp/.claude-credentials \
+ && chmod 644 /tmp/.claude-credentials \
+ && su-exec goclaw mkdir -p /app/data/.claude \
+ && su-exec goclaw cp /tmp/.claude-credentials /app/data/.claude/.credentials.json \
+ && su-exec goclaw chmod 600 /app/data/.claude/.credentials.json \
+ && rm -f /tmp/.claude-credentials \
+ && echo "Claude CLI credentials synced from host.") || echo "WARNING: Claude credentials copy failed (non-fatal)"
+fi
+
# Run command with privilege drop (su-exec in Docker, direct otherwise).
run_as_goclaw() {
if command -v su-exec >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then
diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go
index 3cd5aa85..b27c18fc 100644
--- a/internal/http/provider_verify.go
+++ b/internal/http/provider_verify.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"net/http"
+ "os"
"os/exec"
"path/filepath"
"strings"
@@ -138,11 +139,15 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h
}
}
+ _, dockerErr := os.Stat("/.dockerenv")
+ inDocker := dockerErr == nil
+
status, err := providers.CheckClaudeAuthStatus(ctx, cliPath)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"logged_in": false,
"error": err.Error(),
+ "in_docker": inDocker,
})
return
}
@@ -151,6 +156,7 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h
"logged_in": status.LoggedIn,
"email": status.Email,
"subscription_type": status.SubscriptionType,
+ "in_docker": inDocker,
})
}
diff --git a/ui/web/src/pages/providers/provider-cli-section.tsx b/ui/web/src/pages/providers/provider-cli-section.tsx
index 7444968c..4d1b7f3c 100644
--- a/ui/web/src/pages/providers/provider-cli-section.tsx
+++ b/ui/web/src/pages/providers/provider-cli-section.tsx
@@ -9,6 +9,7 @@ interface CLIAuthStatus {
email?: string;
subscription_type?: string;
error?: string;
+ in_docker?: boolean;
}
export function CLISection({ open }: { open: boolean }) {
@@ -70,7 +71,11 @@ export function CLISection({ open }: { open: boolean }) {
{t("cli.switchAccount")}
{t("cli.switchAccountInstructions")}
-
claude auth logout && claude auth login
+
+ {cliAuth?.in_docker
+ ? "docker compose exec goclaw claude auth logout && docker compose exec goclaw claude auth login"
+ : "claude auth logout && claude auth login"}
+
{t("cli.switchAccountRecheck")} {t("cli.switchAccountRecheckSuffix")}
@@ -97,7 +102,7 @@ export function CLISection({ open }: { open: boolean }) {
{t("cli.runOnServer")}
- claude auth login
+ {cliAuth.in_docker ? "docker compose exec goclaw claude auth login" : "claude auth login"}
{cliAuth.error && (
{cliAuth.error}