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>
This commit is contained in:
Luan VuandLuvu182 authored and GitHub committed 2026-03-25 08:19:52 +07:00
1 parent 19eef35653
commit d63a7d4ced
5 files changed
+32 -4

No files matched your search

+4 -2
View File
@@ -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 \
+1
View File
@@ -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
+14
View File
@@ -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
+6
View File
@@ -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,
})
}
@@ -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 }) {
<summary className="cursor-pointer hover:text-foreground">{t("cli.switchAccount")}</summary>
<div className="mt-1.5 space-y-1 rounded-md border bg-muted/50 px-3 py-2">
<p>{t("cli.switchAccountInstructions")}</p>
<code className="block rounded bg-muted px-2 py-1 font-mono">claude auth logout && claude auth login</code>
<code className="block rounded bg-muted px-2 py-1 font-mono">
{cliAuth?.in_docker
? "docker compose exec goclaw claude auth logout && docker compose exec goclaw claude auth login"
: "claude auth logout && claude auth login"}
</code>
<p>{t("cli.switchAccountRecheck")} <RefreshCw className="inline h-3 w-3" /> {t("cli.switchAccountRecheckSuffix")}</p>
</div>
</details>
@@ -97,7 +102,7 @@ export function CLISection({ open }: { open: boolean }) {
{t("cli.runOnServer")}
</p>
<code className="mt-1 block rounded bg-amber-100 px-2 py-1 text-xs font-mono dark:bg-amber-900 dark:text-amber-300">
claude auth login
{cliAuth.in_docker ? "docker compose exec goclaw claude auth login" : "claude auth login"}
</code>
{cliAuth.error && (
<p className="mt-1 text-xs text-amber-500">{cliAuth.error}</p>