Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ ARG ENABLE_PYTHON=false
ARG ENABLE_NODE=false
ARG ENABLE_FULL_SKILLS=false
ARG ENABLE_CLAUDE_CLI=false
ARG ENABLE_QWEN_CLI=true

# Copy pinned Python deps (cleaned up after install).
# requirements-base.txt: shared deps for ENABLE_PYTHON and ENABLE_FULL_SKILLS.
Expand All @@ -100,14 +101,18 @@ RUN set -eux; \
pip3 install --no-cache-dir --break-system-packages \
-r /tmp/requirements-base.txt; \
fi; \
if [ "$ENABLE_NODE" = "true" ] || [ "$ENABLE_CLAUDE_CLI" = "true" ]; then \
if [ "$ENABLE_NODE" = "true" ] || [ "$ENABLE_CLAUDE_CLI" = "true" ] || [ "$ENABLE_QWEN_CLI" = "true" ]; then \
apk add --no-cache nodejs npm; \
fi; \
fi; \
if [ "$ENABLE_CLAUDE_CLI" = "true" ]; then \
npm install -g --cache /tmp/npm-cache @anthropic-ai/claude-code@^2.1.91; \
rm -rf /tmp/npm-cache; \
fi; \
if [ "$ENABLE_QWEN_CLI" = "true" ]; then \
npm install -g --cache /tmp/npm-cache @qwen-code/qwen-code@latest; \
rm -rf /tmp/npm-cache; \
fi; \
rm -f /tmp/requirements-base.txt /tmp/requirements-skills.txt

# Non-root user
Expand Down Expand Up @@ -143,16 +148,18 @@ RUN chmod +x /app/docker-entrypoint.sh && \
# .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.
# Symlink .qwen → data volume so Qwen 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/data/.claude /app/skills /app/tsnet-state /app/.goclaw \
/app/data/.runtime/pip-cache /app/data/.claude /app/data/.qwen /app/skills /app/tsnet-state /app/.goclaw \
&& ln -s /app/data/.claude /app/.claude \
&& ln -s /app/data/.qwen /app/.qwen \
&& 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 /app/data/.claude
&& chown -R goclaw:goclaw /app/data/.runtime/pip /app/data/.runtime/npm-global /app/data/.runtime/pip-cache /app/data/.claude /app/data/.qwen

# Default environment
ENV GOCLAW_CONFIG=/app/config.json \
Expand Down
20 changes: 20 additions & 0 deletions cmd/gateway_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,26 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
slog.Info("registered provider from DB", "name", p.Name)
continue
}
// Qwen CLI provider — uses qwen binary, no API key needed (OAuth is handled by CLI).
if p.ProviderType == store.ProviderQwenCLI {
cliPath := p.APIBase // reuse APIBase field for CLI path
if cliPath == "" {
cliPath = "qwen"
}
if cliPath != "qwen" && !filepath.IsAbs(cliPath) {
slog.Warn("security.qwen_cli: invalid path from DB, using default", "path", cliPath)
cliPath = "qwen"
}
if _, err := exec.LookPath(cliPath); err != nil {
slog.Warn("qwen-cli: binary not found, skipping", "path", cliPath, "error", err)
continue
}
var cliOpts []providers.QwenCLIOption
cliOpts = append(cliOpts, providers.WithQwenCLIName(p.Name))
registry.RegisterForTenant(p.TenantID, providers.NewQwenCLIProvider(cliPath, cliOpts...))
slog.Info("registered provider from DB", "name", p.Name, "type", "qwen_cli")
continue
}
// ACP provider — no API key needed (agents manage their own auth).
if p.ProviderType == store.ProviderACP {
registerACPFromDB(registry, p)
Expand Down
18 changes: 18 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ if [ -d /app/.claude-host ] && ! command -v claude >/dev/null 2>&1; then
echo "WARNING: Claude credentials mounted but claude CLI not installed. Rebuild with: --build"
fi

# Copy Qwen Code credentials from host mount to goclaw-accessible location.
# /app/.qwen is a symlink → /app/data/.qwen (writable volume, see Dockerfile).
# Use su-exec to copy as goclaw user since container drops DAC_OVERRIDE capability.
if [ -d /app/.qwen-host ] && [ "$(ls -A /app/.qwen-host 2>/dev/null)" ]; then
(mkdir -p /app/data/.qwen \
&& if command -v su-exec >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then
su-exec goclaw cp -r /app/.qwen-host/. /app/data/.qwen/
else
cp -r /app/.qwen-host/. /app/data/.qwen/
fi \
&& echo "Qwen Code credentials synced from host.") || echo "WARNING: Qwen credentials copy failed (non-fatal)"
fi

# Warn if Qwen credentials are mounted but Qwen Code binary is missing.
if [ -d /app/.qwen-host ] && ! command -v qwen >/dev/null 2>&1; then
echo "WARNING: Qwen credentials mounted but Qwen Code CLI not installed."
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
Expand Down
6 changes: 6 additions & 0 deletions internal/http/provider_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
return
}

// Qwen CLI doesn't need an API key — return hardcoded models
if p.ProviderType == store.ProviderQwenCLI {
respond(qwenCLIModels())
return
}

if p.ProviderType == store.ProviderChatGPTOAuth {
respond(chatGPTOAuthModels())
return
Expand Down
7 changes: 7 additions & 0 deletions internal/http/provider_models_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ func claudeCLIModels() []ModelInfo {
}
}

// qwenCLIModels returns the model aliases accepted by the Qwen CLI.
func qwenCLIModels() []ModelInfo {
return []ModelInfo{
{ID: "coder-model", Name: "Coder Model"},
}
}

// acpModels returns the model aliases for ACP-compatible coding agents.
func acpModels() []ModelInfo {
return []ModelInfo{
Expand Down
58 changes: 58 additions & 0 deletions internal/http/provider_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
return
}

// Qwen CLI: verify binary exists on the server (no LLM call needed)
if p.ProviderType == store.ProviderQwenCLI {
binary := p.APIBase
if binary == "" {
binary = "qwen"
}
// Validate binary against known allowlist
if binary != "qwen" && !filepath.IsAbs(binary) {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "invalid binary path"})
return
}
if _, err := exec.LookPath(binary); err != nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "binary not found: " + binary})
} else {
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
}
return
}

if h.providerReg == nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "no provider registry available"})
return
Expand Down Expand Up @@ -159,6 +178,45 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h
})
}

// handleQwenCLIAuthStatus checks whether the Qwen CLI is authenticated on the server.
//
// GET /v1/providers/qwen-cli/auth-status
// Response: {"logged_in": true, "auth_method": "Qwen OAuth", "type": "Free tier"}
// or: {"logged_in": false, "error": "..."}
func (h *ProvidersHandler) handleQwenCLIAuthStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

cliPath := "qwen"
if existing, err := h.store.ListProviders(r.Context()); err == nil {
for _, p := range existing {
if p.ProviderType == "qwen_cli" && p.APIBase != "" {
cliPath = p.APIBase
break
}
}
}

inDocker := config.InDocker()

status, err := providers.CheckQwenAuthStatus(ctx, cliPath)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"logged_in": false,
"error": err.Error(),
"in_docker": inDocker,
})
return
}

writeJSON(w, http.StatusOK, map[string]any{
"logged_in": status.LoggedIn,
"auth_method": status.AuthMethod,
"type": status.Type,
"in_docker": inDocker,
})
}

// isNonChatModel returns true for models that cannot be verified via Chat API
// (image/video generation models).
func isNonChatModel(model string) bool {
Expand Down
3 changes: 3 additions & 0 deletions internal/http/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {

// Claude CLI auth status (global — not per-provider)
mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus))

// Qwen CLI auth status (global — not per-provider)
mux.HandleFunc("GET /v1/providers/qwen-cli/auth-status", h.auth(h.handleQwenCLIAuthStatus))
}

func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
Expand Down
144 changes: 144 additions & 0 deletions internal/providers/qwen_cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package providers

import (
"log/slog"
"os"
"path/filepath"
"sync"

"github.com/nextlevelbuilder/goclaw/internal/config"
)

// QwenCLIProvider implements Provider by shelling out to the `qwen` CLI binary.
// It acts as a thin proxy: CLI manages session history, tool execution, and context.
// GoClaw only forwards the latest user message and streams back the response.
type QwenCLIProvider struct {
name string // provider name (default: "qwen-cli")
cliPath string // path to qwen binary (default: "qwen")
defaultModel string // default: "coder-model"
baseWorkDir string // base dir for agent workspaces
approvalMode string // approval mode (default: "yolo")
mu sync.Mutex // protects workdir creation
sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock
}

// QwenCLIOption configures the provider.
type QwenCLIOption func(*QwenCLIProvider)

// WithQwenCLIName overrides the provider name (default: "qwen-cli").
func WithQwenCLIName(name string) QwenCLIOption {
return func(p *QwenCLIProvider) {
if name != "" {
p.name = name
}
}
}

// WithQwenCLIModel sets the default model.
func WithQwenCLIModel(model string) QwenCLIOption {
return func(p *QwenCLIProvider) {
if model != "" {
p.defaultModel = model
}
}
}

// WithQwenCLIWorkDir sets the base work directory.
func WithQwenCLIWorkDir(dir string) QwenCLIOption {
return func(p *QwenCLIProvider) {
if dir != "" {
p.baseWorkDir = dir
}
}
}

// WithQwenCLIApprovalMode sets the approval mode (plan, default, auto-edit, yolo).
func WithQwenCLIApprovalMode(mode string) QwenCLIOption {
return func(p *QwenCLIProvider) {
if mode != "" {
p.approvalMode = mode
}
}
}

// NewQwenCLIProvider creates a provider that invokes the qwen CLI.
func NewQwenCLIProvider(cliPath string, opts ...QwenCLIOption) *QwenCLIProvider {
if cliPath == "" {
cliPath = "qwen"
}
p := &QwenCLIProvider{
name: "qwen-cli",
cliPath: cliPath,
defaultModel: "coder-model",
baseWorkDir: defaultQwenCLIWorkDir(),
approvalMode: "yolo",
}
for _, opt := range opts {
opt(p)
}
return p
}

func (p *QwenCLIProvider) Name() string { return p.name }
func (p *QwenCLIProvider) DefaultModel() string { return p.defaultModel }

// ProviderType returns the provider type for routing/capability detection.
func (p *QwenCLIProvider) ProviderType() string { return "qwen_cli" }

// Capabilities implements CapabilitiesAware for pipeline code-path selection.
func (p *QwenCLIProvider) Capabilities() ProviderCapabilities {
return ProviderCapabilities{
Streaming: true,
ToolCalling: true,
StreamWithTools: true,
Thinking: true,
Vision: false,
CacheControl: false,
MaxContextWindow: 128_000,
TokenizerID: "cl100k_base",
}
}

// Close cleans up resources. Implements io.Closer.
func (p *QwenCLIProvider) Close() error {
return nil
}

// lockSession acquires a per-session mutex to prevent concurrent CLI calls on the same session.
func (p *QwenCLIProvider) lockSession(sessionKey string) func() {
actual, _ := p.sessionMu.LoadOrStore(sessionKey, &sync.Mutex{})
m := actual.(*sync.Mutex)
m.Lock()
return m.Unlock
}

// ensureWorkDir creates and returns a stable work directory for the given session key.
func (p *QwenCLIProvider) ensureWorkDir(sessionKey string) string {
safe := sanitizePathSegment(sessionKey)
dir := filepath.Join(p.baseWorkDir, safe)

p.mu.Lock()
defer p.mu.Unlock()

if err := os.MkdirAll(dir, 0755); err != nil {
slog.Warn("qwen-cli: failed to create workdir", "dir", dir, "error", err)
return os.TempDir()
}
return dir
}

// writeQwenMD writes the system prompt to QWEN.md in the work directory.
func (p *QwenCLIProvider) writeQwenMD(workDir, systemPrompt string) {
path := filepath.Join(workDir, "QWEN.md")
if existing, err := os.ReadFile(path); err == nil && string(existing) == systemPrompt {
return
}
if err := os.WriteFile(path, []byte(systemPrompt), 0600); err != nil {
slog.Warn("qwen-cli: failed to write QWEN.md", "path", path, "error", err)
}
}

// defaultQwenCLIWorkDir returns dataDir/qwen-cli-workspaces.
func defaultQwenCLIWorkDir() string {
return filepath.Join(config.ResolvedDataDirFromEnv(), "qwen-cli-workspaces")
}
Loading