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}