diff --git a/cmd/gateway.go b/cmd/gateway.go index 58bf7b69..8c3ead74 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -370,7 +370,8 @@ func runGateway() { // Register providers from DB (overrides config providers). if pgStores.Providers != nil { - registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets) + dbGatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) + registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets, dbGatewayAddr, cfg.Gateway.Token) } // Wire embedding provider to PGMemoryStore so IndexDocument generates vectors. @@ -580,7 +581,8 @@ func runGateway() { } contextFileInterceptor, delegateMgr = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader) - agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner) + gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) + agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr) if agentsH != nil { server.SetAgentsHandler(agentsH) } @@ -955,6 +957,9 @@ func runGateway() { channelMgr.StopAll(context.Background()) pgStores.Cron.Stop() + // Close provider resources (e.g. Claude CLI temp files) + providerRegistry.Close() + // Stop sandbox pruning + release containers if sandboxMgr != nil { sandboxMgr.Stop() diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index dfee694c..3a0b855b 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -474,7 +474,7 @@ func wireExtras( } // wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools). -func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler) { +func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler @@ -519,7 +519,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR } if stores != nil && stores.Providers != nil { - providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, token, providerReg) + providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, token, providerReg, gatewayAddr) } if stores != nil && stores.Teams != nil { diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 11e95dba..c5a41e19 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -3,6 +3,10 @@ package cmd import ( "context" "log/slog" + "net" + "os/exec" + "path/filepath" + "strconv" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/oauth" @@ -10,6 +14,15 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// loopbackAddr normalizes a gateway address for local connections. +// CLI processes on the same machine can't connect to 0.0.0.0 on some OSes. +func loopbackAddr(host string, port int) string { + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + return net.JoinHostPort(host, strconv.Itoa(port)) +} + func registerProviders(registry *providers.Registry, cfg *config.Config) { if cfg.Providers.Anthropic.APIKey != "" { registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey, @@ -81,11 +94,40 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus")) slog.Info("registered provider", "name", "bailian") } + + // Claude CLI provider (subscription-based, no API key needed) + if cfg.Providers.ClaudeCLI.CLIPath != "" { + cliPath := cfg.Providers.ClaudeCLI.CLIPath + var opts []providers.ClaudeCLIOption + if cfg.Providers.ClaudeCLI.Model != "" { + opts = append(opts, providers.WithClaudeCLIModel(cfg.Providers.ClaudeCLI.Model)) + } + if cfg.Providers.ClaudeCLI.BaseWorkDir != "" { + opts = append(opts, providers.WithClaudeCLIWorkDir(cfg.Providers.ClaudeCLI.BaseWorkDir)) + } + if cfg.Providers.ClaudeCLI.PermMode != "" { + opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode)) + } + // Build MCP config: external MCP servers + GoClaw bridge (built-in tools via streamable-http) + gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) + mcpPath, mcpCleanup, err := providers.BuildCLIMCPConfig(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token) + if err != nil { + slog.Warn("failed to build MCP config for claude-cli", "error", err) + } else if mcpPath != "" { + opts = append(opts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup)) + } + // Enable GoClaw security hooks (shell deny patterns, path restrictions) + opts = append(opts, providers.WithClaudeCLISecurityHooks( + cfg.Providers.ClaudeCLI.BaseWorkDir, true)) + registry.Register(providers.NewClaudeCLIProvider(cliPath, opts...)) + slog.Info("registered provider", "name", "claude-cli") + } } // registerProvidersFromDB loads providers from Postgres and registers them. // DB providers are registered after config providers, so they take precedence (overwrite). -func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore) { +// gatewayAddr is used to inject GoClaw MCP bridge for Claude CLI providers. +func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string) { ctx := context.Background() dbProviders, err := provStore.ListProviders(ctx) if err != nil { @@ -93,7 +135,39 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi return } for _, p := range dbProviders { - if !p.Enabled || p.APIKey == "" { + // Claude CLI doesn't need API key + if !p.Enabled { + continue + } + if p.ProviderType == store.ProviderClaudeCLI { + cliPath := p.APIBase // reuse APIBase field for CLI path + if cliPath == "" { + cliPath = "claude" + } + // Validate: only accept "claude" or absolute path + if cliPath != "claude" && !filepath.IsAbs(cliPath) { + slog.Warn("security.claude_cli: invalid path from DB, using default", "path", cliPath) + cliPath = "claude" + } + if _, err := exec.LookPath(cliPath); err != nil { + slog.Warn("claude-cli: binary not found, skipping", "path", cliPath, "error", err) + continue + } + var cliOpts []providers.ClaudeCLIOption + cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true)) + if gatewayAddr != "" { + mcpPath, mcpCleanup, mcpErr := providers.BuildCLIMCPConfig(nil, gatewayAddr, gatewayToken) + if mcpErr != nil { + slog.Warn("failed to build MCP config for db claude-cli", "error", mcpErr) + } else if mcpPath != "" { + cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup)) + } + } + registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) + slog.Info("registered provider from DB", "name", p.Name) + continue + } + if p.APIKey == "" { continue } if p.ProviderType == store.ProviderChatGPTOAuth { diff --git a/cmd/onboard.go b/cmd/onboard.go index cc4ee67a..cda3e4cb 100644 --- a/cmd/onboard.go +++ b/cmd/onboard.go @@ -1,14 +1,17 @@ package cmd import ( + "context" "fmt" "os" + "os/exec" "path/filepath" "strconv" "github.com/spf13/cobra" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" ) func onboardCmd() *cobra.Command { @@ -39,6 +42,7 @@ var providerMap = map[string]providerInfo{ "minimax": {"minimax", "GOCLAW_MINIMAX_API_KEY", "MiniMax-M2.5"}, "cohere": {"cohere", "GOCLAW_COHERE_API_KEY", "command-a"}, "perplexity": {"perplexity", "GOCLAW_PERPLEXITY_API_KEY", "sonar-pro"}, + "claude_cli": {"claude-cli", "", "sonnet"}, "custom": {"custom", "", ""}, } @@ -185,6 +189,7 @@ func runOnboard() { {"MiniMax (MiniMax models)", "minimax"}, {"Cohere (Command models)", "cohere"}, {"Perplexity (Sonar search models)", "perplexity"}, + {"Claude CLI (use local claude CLI — no API key needed)", "claude_cli"}, {"Custom (any OpenAI-compatible endpoint)", "custom"}, } @@ -195,8 +200,76 @@ func runOnboard() { return } + // Claude CLI variables + var cliPath, cliModel string + if cfg.Providers.ClaudeCLI.CLIPath != "" { + cliPath = cfg.Providers.ClaudeCLI.CLIPath + cliModel = cfg.Providers.ClaudeCLI.Model + } + // Provider-specific prompts switch providerChoice { + case "claude_cli": + if cliPath == "" { + cliPath = "claude" + } + cliPath, err = promptString("Claude CLI Path", "Path to the claude binary", cliPath) + if err != nil { + fmt.Println("Cancelled.") + return + } + + // Verify CLI binary exists + if _, lookErr := exec.LookPath(cliPath); lookErr != nil { + fmt.Printf(" ✗ Claude CLI binary not found at %q\n", cliPath) + fmt.Println(" Install Claude CLI first: https://docs.anthropic.com/en/docs/claude-cli") + return + } + + // Check authentication status + fmt.Print(" Checking Claude CLI authentication... ") + authStatus, authErr := providers.CheckClaudeAuthStatus(context.Background(), cliPath) + if authErr != nil || !authStatus.LoggedIn { + if authErr != nil { + fmt.Println("FAILED") + fmt.Printf(" %v\n", authErr) + } else { + fmt.Println("NOT LOGGED IN") + } + fmt.Println() + runLogin, confirmErr := promptConfirm("Claude CLI requires authentication. Run login now?", true) + if confirmErr != nil { + fmt.Println("Cancelled.") + return + } + if runLogin { + if loginErr := runClaudeAuthLogin(cliPath); loginErr != nil { + fmt.Printf(" Login failed: %v\n", loginErr) + fmt.Println(" You can try manually: claude auth login") + return + } + } else { + fmt.Println(" Skipping login. Run 'claude auth login' before starting the gateway.") + } + } else { + sub := authStatus.SubscriptionType + if sub == "" { + sub = "unknown" + } + fmt.Printf("OK\n") + fmt.Printf(" ✓ Authenticated as %s (subscription: %s)\n", authStatus.Email, sub) + } + fmt.Println() + + if cliModel == "" { + cliModel = "sonnet" + } + cliModel, err = promptString("Default Model", "Model alias: sonnet, opus, haiku", cliModel) + if err != nil { + fmt.Println("Cancelled.") + return + } + case "custom": customAPIBase, err = promptString("API Base URL", "OpenAI-compatible endpoint (e.g. Ollama, vLLM, LiteLLM)", customAPIBase) if err != nil { @@ -360,6 +433,10 @@ func runOnboard() { if customModel == "" { errors = append(errors, "Model ID is required for custom provider") } + } else if providerChoice == "claude_cli" { + if cliPath == "" { + errors = append(errors, "Claude CLI path is required") + } } else if apiKey == "" { errors = append(errors, fmt.Sprintf("API key is required for %s", providerChoice)) } @@ -400,7 +477,12 @@ func runOnboard() { // --- Apply collected values to config --- // Provider & model - if providerChoice == "custom" { + if providerChoice == "claude_cli" { + cfg.Agents.Defaults.Provider = "claude-cli" + cfg.Agents.Defaults.Model = cliModel + cfg.Providers.ClaudeCLI.CLIPath = cliPath + cfg.Providers.ClaudeCLI.Model = cliModel + } else if providerChoice == "custom" { cfg.Agents.Defaults.Provider = "openai" cfg.Providers.OpenAI.APIBase = customAPIBase cfg.Providers.OpenAI.APIKey = apiKey @@ -562,7 +644,10 @@ func runOnboard() { savedTtsElevenLabsKey := cfg.Tts.ElevenLabs.APIKey savedTtsMiniMaxKey := cfg.Tts.MiniMax.APIKey - cfg.Providers = config.ProvidersConfig{} + // Clear secrets from providers but keep non-secret configs (like ClaudeCLI) + cfg.Providers = config.ProvidersConfig{ + ClaudeCLI: savedProviders.ClaudeCLI, // no secrets, safe to save in config + } cfg.Gateway.Token = "" cfg.Channels.Telegram.Token = "" cfg.Channels.Zalo.Token = "" diff --git a/cmd/onboard_claude_cli.go b/cmd/onboard_claude_cli.go new file mode 100644 index 00000000..f2d740b8 --- /dev/null +++ b/cmd/onboard_claude_cli.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" +) + +// runClaudeAuthLogin runs `claude auth login` interactively, +// connecting stdin/stdout/stderr to the terminal for the browser OAuth flow. +func runClaudeAuthLogin(cliPath string) error { + fmt.Println(" Opening browser for Claude authentication...") + cmd := exec.Command(cliPath, "auth", "login") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/cmd/onboard_helpers.go b/cmd/onboard_helpers.go index eb045aa9..19d363e8 100644 --- a/cmd/onboard_helpers.go +++ b/cmd/onboard_helpers.go @@ -26,6 +26,14 @@ func onboardWriteEnvFile(path string, cfg *config.Config, primaryKey, primaryEnv lines = append(lines, fmt.Sprintf("export %s=%s", primaryEnvKey, primaryKey)) } + // Claude CLI config (no API key, just path/model) + if cfg.Providers.ClaudeCLI.CLIPath != "" { + lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_PATH=%s", cfg.Providers.ClaudeCLI.CLIPath)) + if cfg.Providers.ClaudeCLI.Model != "" { + lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_MODEL=%s", cfg.Providers.ClaudeCLI.Model)) + } + } + // Add other configured provider keys (skip if same as primary) addIfSet := func(envKey, value string) { if value != "" && (primaryEnvKey == "" || envKey != primaryEnvKey) { diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 59aa9d7d..97f83c0a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -426,6 +426,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) Options: map[string]interface{}{ providers.OptMaxTokens: 8192, providers.OptTemperature: 0.7, + providers.OptSessionKey: req.SessionKey, }, } if l.thinkingLevel != "" && l.thinkingLevel != "off" { diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 5b927066..fd9a5845 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -147,19 +147,28 @@ type FeishuConfig struct { // ProvidersConfig maps provider name to its config. type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Gemini ProviderConfig `json:"gemini"` - DeepSeek ProviderConfig `json:"deepseek"` - Mistral ProviderConfig `json:"mistral"` - XAI ProviderConfig `json:"xai"` - MiniMax ProviderConfig `json:"minimax"` - Cohere ProviderConfig `json:"cohere"` - Perplexity ProviderConfig `json:"perplexity"` - DashScope ProviderConfig `json:"dashscope"` - Bailian ProviderConfig `json:"bailian"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI ProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Gemini ProviderConfig `json:"gemini"` + DeepSeek ProviderConfig `json:"deepseek"` + Mistral ProviderConfig `json:"mistral"` + XAI ProviderConfig `json:"xai"` + MiniMax ProviderConfig `json:"minimax"` + Cohere ProviderConfig `json:"cohere"` + Perplexity ProviderConfig `json:"perplexity"` + DashScope ProviderConfig `json:"dashscope"` + Bailian ProviderConfig `json:"bailian"` + ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` +} + +// ClaudeCLIConfig configures the Claude CLI provider (uses subscription, not API key). +type ClaudeCLIConfig struct { + CLIPath string `json:"cli_path" yaml:"cli_path"` // path to claude binary (default: "claude") + Model string `json:"model" yaml:"model"` // default model alias (default: "sonnet") + BaseWorkDir string `json:"base_work_dir" yaml:"base_work_dir"` // base dir for agent workspaces + PermMode string `json:"perm_mode" yaml:"perm_mode"` // permission mode (default: "bypassPermissions") } type ProviderConfig struct { @@ -167,7 +176,7 @@ type ProviderConfig struct { APIBase string `json:"api_base,omitempty"` } -// HasAnyProvider returns true if at least one provider has an API key configured. +// HasAnyProvider returns true if at least one provider has an API key or CLI configured. func (c *Config) HasAnyProvider() bool { p := c.Providers return p.Anthropic.APIKey != "" || @@ -182,7 +191,8 @@ func (c *Config) HasAnyProvider() bool { p.Cohere.APIKey != "" || p.Perplexity.APIKey != "" || p.DashScope.APIKey != "" || - p.Bailian.APIKey != "" + p.Bailian.APIKey != "" || + p.ClaudeCLI.CLIPath != "" } // QuotaWindow defines request limits per time window. Zero means unlimited. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 19143c0b..dccbc1e2 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -143,6 +143,11 @@ func (c *Config) applyEnvOverrides() { c.Channels.WhatsApp.Enabled = true } + // Claude CLI provider + envStr("GOCLAW_CLAUDE_CLI_PATH", &c.Providers.ClaudeCLI.CLIPath) + envStr("GOCLAW_CLAUDE_CLI_MODEL", &c.Providers.ClaudeCLI.Model) + envStr("GOCLAW_CLAUDE_CLI_WORK_DIR", &c.Providers.ClaudeCLI.BaseWorkDir) + // Default provider/model: env overrides config. envStr("GOCLAW_PROVIDER", &c.Agents.Defaults.Provider) envStr("GOCLAW_MODEL", &c.Agents.Defaults.Model) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 28c04eb8..6e53c2ef 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -16,6 +16,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" + mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" @@ -198,10 +199,35 @@ func (s *Server) BuildMux() *http.ServeMux { s.oauthHandler.RegisterRoutes(mux) } + // MCP bridge: expose GoClaw tools to Claude CLI via streamable-http. + // Only listens on localhost (CLI runs on the same machine). + // Protected by gateway token when configured. + if s.tools != nil { + bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0") + if s.cfg.Gateway.Token != "" { + mux.Handle("/mcp/bridge", tokenAuthMiddleware(s.cfg.Gateway.Token, bridgeHandler)) + } else { + slog.Warn("security.mcp_bridge: no gateway token configured, MCP bridge tools are unauthenticated") + mux.Handle("/mcp/bridge", bridgeHandler) + } + } + s.mux = mux return mux } +// tokenAuthMiddleware wraps an http.Handler with Bearer token authentication. +func tokenAuthMiddleware(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != token { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + // Start begins listening for WebSocket and HTTP connections. func (s *Server) Start(ctx context.Context) error { mux := s.BuildMux() diff --git a/internal/http/agents.go b/internal/http/agents.go index d674cd5d..582f9ff6 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "github.com/google/uuid" @@ -117,6 +118,12 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { return } + // Check for duplicate agent_key before creating + if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with key \"" + req.AgentKey + "\" already exists"}) + return + } + req.OwnerID = userID if req.AgentType == "" { req.AgentType = store.AgentTypeOpen @@ -149,7 +156,11 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { } if err := h.agents.Create(r.Context(), &req); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") { + writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with this key already exists"}) + } else { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } return } diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index a693d9ae..9e3ad66b 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // ModelInfo is a normalized model entry returned by the list-models endpoint. @@ -36,6 +37,12 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht return } + // Claude CLI doesn't need an API key — return hardcoded models + if p.ProviderType == store.ProviderClaudeCLI { + writeJSON(w, http.StatusOK, map[string]interface{}{"models": claudeCLIModels()}) + return + } + if p.APIKey == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"}) return @@ -175,6 +182,15 @@ func minimaxModels() []ModelInfo { } } +// claudeCLIModels returns the model aliases accepted by the Claude CLI. +func claudeCLIModels() []ModelInfo { + return []ModelInfo{ + {ID: "sonnet", Name: "Sonnet"}, + {ID: "opus", Name: "Opus"}, + {ID: "haiku", Name: "Haiku"}, + } +} + // fetchOpenAIModels calls an OpenAI-compatible /models endpoint. func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil) diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 303c8dab..20cccba3 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -42,6 +42,17 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } + // Claude CLI: validate model alias locally (no LLM call needed) + if p.ProviderType == "claude_cli" { + validModels := map[string]bool{"sonnet": true, "opus": true, "haiku": true} + if validModels[req.Model] { + writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true}) + } else { + writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"}) + } + return + } + if h.providerReg == nil { writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "no provider registry available"}) return @@ -73,6 +84,42 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true}) } +// handleClaudeCLIAuthStatus checks whether the Claude CLI is authenticated on the server. +// +// GET /v1/providers/claude-cli/auth-status +// Response: {"logged_in": true, "email": "...", "subscription_type": "max"} +// or: {"logged_in": false, "error": "..."} +func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Try to find CLI path from existing Claude CLI provider in DB + cliPath := "claude" + if existing, err := h.store.ListProviders(r.Context()); err == nil { + for _, p := range existing { + if p.ProviderType == "claude_cli" && p.APIBase != "" { + cliPath = p.APIBase + break + } + } + } + + status, err := providers.CheckClaudeAuthStatus(ctx, cliPath) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "logged_in": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "logged_in": status.LoggedIn, + "email": status.Email, + "subscription_type": status.SubscriptionType, + }) +} + // friendlyVerifyError extracts a human-readable message from provider errors. // Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}` func friendlyVerifyError(err error) string { diff --git a/internal/http/providers.go b/internal/http/providers.go index 7f4d8d5d..d07aedf4 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "sync" "github.com/google/uuid" @@ -18,11 +19,13 @@ type ProvidersHandler struct { secretStore store.ConfigSecretsStore token string providerReg *providers.Registry + gatewayAddr string // for injecting MCP bridge into Claude CLI providers + cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates } // NewProvidersHandler creates a handler for provider management endpoints. -func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, token string, providerReg *providers.Registry) *ProvidersHandler { - return &ProvidersHandler{store: s, secretStore: secretStore, token: token, providerReg: providerReg} +func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, token string, providerReg *providers.Registry, gatewayAddr string) *ProvidersHandler { + return &ProvidersHandler{store: s, secretStore: secretStore, token: token, providerReg: providerReg, gatewayAddr: gatewayAddr} } // RegisterRoutes registers all provider management routes on the given mux. @@ -39,6 +42,9 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { // Provider + model verification (pre-flight check) mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider)) + + // Claude CLI auth status (global — not per-provider) + mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus)) } func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -63,7 +69,29 @@ func maskAPIKey(p *store.LLMProviderData) { // registerInMemory adds (or replaces) a provider in the in-memory registry // so it's immediately usable for verify/chat without a gateway restart. func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { - if h.providerReg == nil || !p.Enabled || p.APIKey == "" { + if h.providerReg == nil || !p.Enabled { + return + } + // Claude CLI doesn't need an API key — register immediately + if p.ProviderType == store.ProviderClaudeCLI { + cliPath := p.APIBase // reuse APIBase field for CLI path + if cliPath == "" { + cliPath = "claude" + } + var cliOpts []providers.ClaudeCLIOption + cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true)) + if h.gatewayAddr != "" { + mcpPath, mcpCleanup, mcpErr := providers.BuildCLIMCPConfig(nil, h.gatewayAddr, h.token) + if mcpErr != nil { + slog.Warn("failed to build MCP config for in-memory claude-cli", "error", mcpErr) + } else if mcpPath != "" { + cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup)) + } + } + h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) + return + } + if p.APIKey == "" { return } if p.ProviderType == store.ProviderChatGPTOAuth { @@ -126,6 +154,23 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R return } + // Only one Claude CLI provider is allowed per instance (1 machine = 1 auth session). + // Mutex serializes check+create to prevent TOCTOU race. + if p.ProviderType == store.ProviderClaudeCLI { + h.cliMu.Lock() + defer h.cliMu.Unlock() + + existing, _ := h.store.ListProviders(r.Context()) + for _, ep := range existing { + if ep.ProviderType == store.ProviderClaudeCLI { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "A Claude CLI provider already exists. Only one is allowed per instance.", + }) + return + } + } + } + if err := h.store.CreateProvider(r.Context(), &p); err != nil { slog.Error("providers.create", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) diff --git a/internal/http/summoner.go b/internal/http/summoner.go index ef350cc4..8c1d6f0c 100644 --- a/internal/http/summoner.go +++ b/internal/http/summoner.go @@ -307,23 +307,32 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, return nil, fmt.Errorf("resolve provider: %w", err) } + // Use a unique session key so CLI-based providers get an isolated workdir + // (prevents polluting/reading CLAUDE.md from active chat sessions). + summonSessionKey := "summon-" + uuid.New().String() + slog.Info("summoning: calling LLM", "provider", providerName, "model", model, "prompt_len", len(prompt)) resp, err := provider.Chat(ctx, providers.ChatRequest{ Messages: []providers.Message{ + {Role: "system", Content: "You are a file generator. Output ONLY the requested XML-tagged files. No extra commentary."}, {Role: "user", Content: prompt}, }, Model: model, Options: map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, + "max_tokens": 8192, + "temperature": 0.7, + providers.OptSessionKey: summonSessionKey, + providers.OptDisableTools: true, }, }) if err != nil { return nil, fmt.Errorf("%s: %w", providerName, err) } - slog.Info("summoning: LLM responded", "provider", providerName, "content_len", len(resp.Content)) + slog.Info("summoning: raw LLM response", "provider", providerName, "length", len(resp.Content), + "preview_start", truncateUTF8(resp.Content, 500), + "preview_end", truncateUTF8(suffixString(resp.Content, 500), 500)) files := parseFileResponse(resp.Content) if len(files) == 0 { @@ -672,6 +681,15 @@ func extractIdentityName(content string) string { return strings.TrimSpace(m[1]) } +// suffixString returns the last n runes of s. +func suffixString(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[len(runes)-n:]) +} + // truncateUTF8 truncates s to at most maxLen runes, appending "…" if truncated. func truncateUTF8(s string, maxLen int) string { runes := []rune(s) diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go new file mode 100644 index 00000000..106fc389 --- /dev/null +++ b/internal/mcp/bridge_server.go @@ -0,0 +1,98 @@ +package mcp + +import ( + "context" + "encoding/json" + "log/slog" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge. +// Excluded: spawn (agent loop), message/create_forum_topic (channels), +// handoff/delegate_search/evaluate_loop/team_* (managed mode stores). +var BridgeToolNames = map[string]bool{ + // Filesystem + "read_file": true, + "write_file": true, + "list_files": true, + "edit": true, + "exec": true, + // Web + "web_search": true, + "web_fetch": true, + // Memory & knowledge + "memory_search": true, + "memory_get": true, + "skill_search": true, + // Media + "read_image": true, + "create_image": true, + "tts": true, + // Browser automation + "browser": true, + // Scheduler + "cron": true, + // Sessions (read + send) + "sessions_list": true, + "session_status": true, + "sessions_history": true, + "sessions_send": true, +} + +// NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools. +// It reads tools from the registry, filters to BridgeToolNames, and serves them +// over streamable-http transport (stateless mode). +func NewBridgeServer(reg *tools.Registry, version string) *mcpserver.StreamableHTTPServer { + srv := mcpserver.NewMCPServer("goclaw-bridge", version, + mcpserver.WithToolCapabilities(false), + ) + + // Register each safe tool from the GoClaw registry + var registered int + for name := range BridgeToolNames { + t, ok := reg.Get(name) + if !ok { + continue + } + + mcpTool := convertToMCPTool(t) + handler := makeToolHandler(reg, name) + srv.AddTool(mcpTool, handler) + registered++ + } + + slog.Info("mcp.bridge: tools registered", "count", registered) + + return mcpserver.NewStreamableHTTPServer(srv, + mcpserver.WithStateLess(true), + ) +} + +// convertToMCPTool converts a GoClaw tools.Tool into an mcp-go Tool. +func convertToMCPTool(t tools.Tool) mcpgo.Tool { + schema, err := json.Marshal(t.Parameters()) + if err != nil { + // Fallback: empty object schema + schema = []byte(`{"type":"object"}`) + } + return mcpgo.NewToolWithRawSchema(t.Name(), t.Description(), schema) +} + +// makeToolHandler creates a ToolHandlerFunc that delegates to the GoClaw tool registry. +func makeToolHandler(reg *tools.Registry, toolName string) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + args := req.GetArguments() + + result := reg.Execute(ctx, toolName, args) + + if result.IsError { + return mcpgo.NewToolResultError(result.ForLLM), nil + } + + return mcpgo.NewToolResultText(result.ForLLM), nil + } +} diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go new file mode 100644 index 00000000..e42c70b8 --- /dev/null +++ b/internal/providers/claude_cli.go @@ -0,0 +1,124 @@ +package providers + +import ( + "log/slog" + "sync" +) + +// Options key for passing session key from agent loop to CLI provider. +const OptSessionKey = "session_key" + +// OptDisableTools disables all built-in CLI tools when set to true. +// Useful for pure text generation (e.g. summoning) where tool use is unwanted. +const OptDisableTools = "disable_tools" + +// ClaudeCLIProvider implements Provider by shelling out to the `claude` 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 ClaudeCLIProvider struct { + cliPath string // path to claude binary (default: "claude") + defaultModel string // default: "sonnet" + baseWorkDir string // base dir for agent workspaces + mcpConfigPath string // pre-built MCP config file path (empty = no MCP) + permMode string // permission mode (default: "bypassPermissions") + hooksSettingsPath string // generated settings.json with security hooks (empty = no hooks) + hooksCleanup func() // cleanup function for hooks temp files + mcpCleanup func() // cleanup function for MCP config temp file + mu sync.Mutex // protects workdir creation + sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock +} + +// ClaudeCLIOption configures the provider. +type ClaudeCLIOption func(*ClaudeCLIProvider) + +// WithClaudeCLIModel sets the default model alias. +func WithClaudeCLIModel(model string) ClaudeCLIOption { + return func(p *ClaudeCLIProvider) { + if model != "" { + p.defaultModel = model + } + } +} + +// WithClaudeCLIWorkDir sets the base work directory. +func WithClaudeCLIWorkDir(dir string) ClaudeCLIOption { + return func(p *ClaudeCLIProvider) { + if dir != "" { + p.baseWorkDir = dir + } + } +} + +// WithClaudeCLIMCPConfig sets the MCP config file path. +func WithClaudeCLIMCPConfig(path string, cleanup ...func()) ClaudeCLIOption { + return func(p *ClaudeCLIProvider) { + p.mcpConfigPath = path + if len(cleanup) > 0 && cleanup[0] != nil { + p.mcpCleanup = cleanup[0] + } + } +} + +// WithClaudeCLIPermMode sets the permission mode. +func WithClaudeCLIPermMode(mode string) ClaudeCLIOption { + return func(p *ClaudeCLIProvider) { + if mode != "" { + p.permMode = mode + } + } +} + +// WithClaudeCLISecurityHooks enables GoClaw security hooks for CLI tool calls. +// Generates a settings file with PreToolUse hooks that enforce shell deny patterns +// and workspace path restrictions. +func WithClaudeCLISecurityHooks(workspace string, restrictToWorkspace bool) ClaudeCLIOption { + return func(p *ClaudeCLIProvider) { + settingsPath, cleanup, err := BuildCLIHooksConfig(workspace, restrictToWorkspace) + if err != nil { + slog.Warn("claude-cli: failed to build security hooks", "error", err) + return + } + p.hooksSettingsPath = settingsPath + p.hooksCleanup = cleanup + } +} + +// NewClaudeCLIProvider creates a provider that invokes the claude CLI. +func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIProvider { + if cliPath == "" { + cliPath = "claude" + } + p := &ClaudeCLIProvider{ + cliPath: cliPath, + defaultModel: "sonnet", + baseWorkDir: defaultCLIWorkDir(), + permMode: "bypassPermissions", + // sessionMu is zero-value ready (sync.Map) + } + for _, opt := range opts { + opt(p) + } + return p +} + +func (p *ClaudeCLIProvider) Name() string { return "claude-cli" } +func (p *ClaudeCLIProvider) DefaultModel() string { return p.defaultModel } + +// Close cleans up temp files (MCP config, hooks settings). Implements io.Closer. +func (p *ClaudeCLIProvider) Close() error { + if p.mcpCleanup != nil { + p.mcpCleanup() + } + if p.hooksCleanup != nil { + p.hooksCleanup() + } + return nil +} + +// lockSession acquires a per-session mutex to prevent concurrent CLI calls on the same session. +func (p *ClaudeCLIProvider) lockSession(sessionKey string) func() { + actual, _ := p.sessionMu.LoadOrStore(sessionKey, &sync.Mutex{}) + m := actual.(*sync.Mutex) + m.Lock() + return m.Unlock +} diff --git a/internal/providers/claude_cli_auth.go b/internal/providers/claude_cli_auth.go new file mode 100644 index 00000000..e0d1a4ca --- /dev/null +++ b/internal/providers/claude_cli_auth.go @@ -0,0 +1,40 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" +) + +// ClaudeAuthStatus holds the parsed result of `claude auth status --json`. +type ClaudeAuthStatus struct { + LoggedIn bool `json:"loggedIn"` + Email string `json:"email,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` +} + +// CheckClaudeAuthStatus runs `claude auth status --json` using the given CLI +// path and returns the parsed authentication status. +func CheckClaudeAuthStatus(ctx context.Context, cliPath string) (*ClaudeAuthStatus, error) { + if cliPath == "" { + cliPath = "claude" + } + + resolvedPath, err := exec.LookPath(cliPath) + if err != nil { + return nil, fmt.Errorf("claude CLI binary not found at %q: %w", cliPath, err) + } + + cmd := exec.CommandContext(ctx, resolvedPath, "auth", "status", "--json") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("claude auth status failed: %w", err) + } + + var status ClaudeAuthStatus + if err := json.Unmarshal(output, &status); err != nil { + return nil, fmt.Errorf("failed to parse auth status: %w", err) + } + return &status, nil +} diff --git a/internal/providers/claude_cli_chat.go b/internal/providers/claude_cli_chat.go new file mode 100644 index 00000000..8327eaab --- /dev/null +++ b/internal/providers/claude_cli_chat.go @@ -0,0 +1,215 @@ +package providers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Chat runs the CLI synchronously and returns the final response. +func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + systemPrompt, userMsg, images := extractFromMessages(req.Messages) + sessionKey := extractSessionKey(req.Options) + model := req.Model + if model == "" { + model = p.defaultModel + } + if err := validateCLIModel(model); err != nil { + return nil, err + } + + unlock := p.lockSession(sessionKey) + defer unlock() + + workDir := p.ensureWorkDir(sessionKey) + if systemPrompt != "" { + p.writeClaudeMD(workDir, systemPrompt) + } + + cliSessionID := deriveSessionUUID(sessionKey) + disableTools := extractDisableTools(req.Options) + args := p.buildArgs(model, workDir, cliSessionID, "json", len(images) > 0, disableTools) + + var stdin *bytes.Reader + if len(images) > 0 { + stdin = buildStreamJSONInput(userMsg, images) + } else { + args = append(args, "--", userMsg) + } + + cmd := exec.CommandContext(ctx, p.cliPath, args...) + cmd.Dir = workDir + cmd.Env = filterCLIEnv(os.Environ()) + if stdin != nil { + cmd.Stdin = stdin + } + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + slog.Debug("claude-cli exec", "cmd", fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")), "workdir", workDir) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderr.String()) + } + + return parseJSONResponse(output) +} + +// ChatStream runs the CLI with stream-json output, calling onChunk for each text delta. +func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + systemPrompt, userMsg, images := extractFromMessages(req.Messages) + sessionKey := extractSessionKey(req.Options) + model := req.Model + if model == "" { + model = p.defaultModel + } + if err := validateCLIModel(model); err != nil { + return nil, err + } + + slog.Debug("claude-cli: acquiring session lock", "session_key", sessionKey) + unlock := p.lockSession(sessionKey) + slog.Debug("claude-cli: session lock acquired", "session_key", sessionKey) + defer func() { + unlock() + slog.Debug("claude-cli: session lock released", "session_key", sessionKey) + }() + + workDir := p.ensureWorkDir(sessionKey) + if systemPrompt != "" { + p.writeClaudeMD(workDir, systemPrompt) + } + + cliSessionID := deriveSessionUUID(sessionKey) + disableToolsStream := extractDisableTools(req.Options) + args := p.buildArgs(model, workDir, cliSessionID, "stream-json", len(images) > 0, disableToolsStream) + + var stdin *bytes.Reader + if len(images) > 0 { + stdin = buildStreamJSONInput(userMsg, images) + } else { + args = append(args, "--", userMsg) + } + + cmd := exec.CommandContext(ctx, p.cliPath, args...) + cmd.Dir = workDir + cmd.Env = filterCLIEnv(os.Environ()) + if stdin != nil { + cmd.Stdin = stdin + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("claude-cli stdout pipe: %w", err) + } + + fullCmd := fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")) + slog.Debug("claude-cli stream exec", "cmd", fullCmd, "workdir", workDir) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("claude-cli start: %w", err) + } + + // Debug log file: only enabled when GOCLAW_DEBUG=1 + var debugFile *os.File + if os.Getenv("GOCLAW_DEBUG") == "1" { + debugLogPath := filepath.Join(workDir, "cli-debug.log") + debugFile, _ = os.OpenFile(debugLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if debugFile != nil { + fmt.Fprintf(debugFile, "=== CMD: %s\n=== WORKDIR: %s\n=== TIME: %s\n\n", fullCmd, workDir, time.Now().Format(time.RFC3339)) + defer debugFile.Close() + } + } + + // Parse stream-json line-by-line + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) // 256KB initial, 1MB max + + var finalResp ChatResponse + var contentBuf strings.Builder + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + // Write raw line to debug log + if debugFile != nil { + fmt.Fprintf(debugFile, "%s\n", line) + } + + var ev cliStreamEvent + if err := json.Unmarshal(line, &ev); err != nil { + slog.Debug("claude-cli: skip malformed stream line", "error", err) + continue + } + + switch ev.Type { + case "assistant": + if ev.Message == nil { + continue + } + text, thinking := extractStreamContent(ev.Message) + if text != "" { + contentBuf.WriteString(text) + onChunk(StreamChunk{Content: text}) + } + if thinking != "" { + onChunk(StreamChunk{Thinking: thinking}) + } + + case "result": + if ev.Result != "" { + finalResp.Content = ev.Result + } else { + finalResp.Content = contentBuf.String() + } + finalResp.FinishReason = "stop" + if ev.Subtype == "error" { + finalResp.FinishReason = "error" + } + if ev.Usage != nil { + finalResp.Usage = &Usage{ + PromptTokens: ev.Usage.InputTokens, + CompletionTokens: ev.Usage.OutputTokens, + TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens, + } + } + } + } + + if err := cmd.Wait(); err != nil { + if debugFile != nil { + fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n=== EXIT ERROR: %v\n", stderrBuf.String(), err) + } + // If we got partial content, return it with the error + if finalResp.Content != "" { + return &finalResp, nil + } + return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderrBuf.String()) + } + if debugFile != nil && stderrBuf.Len() > 0 { + fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n", stderrBuf.String()) + } + + // Fallback if no "result" event was received + if finalResp.Content == "" { + finalResp.Content = contentBuf.String() + finalResp.FinishReason = "stop" + } + + onChunk(StreamChunk{Done: true}) + return &finalResp, nil +} diff --git a/internal/providers/claude_cli_deny_patterns.go b/internal/providers/claude_cli_deny_patterns.go new file mode 100644 index 00000000..8eed2566 --- /dev/null +++ b/internal/providers/claude_cli_deny_patterns.go @@ -0,0 +1,109 @@ +package providers + +// ShellDenyPatterns are regex patterns for dangerous shell commands. +// Mirrors internal/tools/shell.go defaultDenyPatterns for CLI hook enforcement. +var ShellDenyPatterns = []string{ + // Destructive file operations + `\brm\s+-[rf]{1,2}\b`, + `\brm\s+.*--recursive`, + `\brm\s+.*--force`, + `\bdel\s+/[fq]\b`, + `\brmdir\s+/s\b`, + `\b(mkfs|diskpart)\b|\bformat\s`, + `\bdd\s+if=`, + `>\s*/dev/sd[a-z]\b`, + `\b(shutdown|reboot|poweroff)\b`, + `:\(\)\s*\{.*\};\s*:`, + + // Data exfiltration + `\bcurl\b.*\|\s*(ba)?sh\b`, + `\bcurl\b.*(-d\b|-F\b|--data|--upload|--form|-T\b|-X\s*P(UT|OST|ATCH))`, + `\bwget\b.*-O\s*-\s*\|\s*(ba)?sh\b`, + `\bwget\b.*--post-(data|file)`, + `\b(nslookup|dig|host)\b`, + `/dev/tcp/`, + + // Reverse shells + `\b(nc|ncat|netcat)\b.*-[el]\b`, + `\bsocat\b`, + `\bopenssl\b.*s_client`, + `\btelnet\b.*[0-9]+`, + `\bpython[23]?\b.*\bimport\s+(socket|http\.client|urllib|requests)\b`, + `\bperl\b.*-e\s*.*\b[Ss]ocket\b`, + `\bruby\b.*-e\s*.*\b(TCPSocket|Socket)\b`, + `\bnode\b.*-e\s*.*\b(net\.connect|child_process)\b`, + `\bawk\b.*/inet/`, + `\bmkfifo\b`, + + // Dangerous eval / code injection + `\beval\s*\$`, + `\bbase64\s+-d\b.*\|\s*(ba)?sh\b`, + + // Privilege escalation + `\bsudo\b`, + `\bsu\s+-`, + `\bnsenter\b`, + `\bunshare\b`, + `\b(mount|umount)\b`, + `\b(capsh|setcap|getcap)\b`, + + // Dangerous path operations + `\bchmod\s+[0-7]{3,4}\s+/`, + `\bchown\b.*\s+/`, + `\bchmod\b.*\+x.*/tmp/`, + `\bchmod\b.*\+x.*/var/tmp/`, + `\bchmod\b.*\+x.*/dev/shm/`, + + // Environment variable injection + `\bLD_PRELOAD\s*=`, + `\bDYLD_INSERT_LIBRARIES\s*=`, + `\bLD_LIBRARY_PATH\s*=`, + `/etc/ld\.so\.preload`, + `\bGIT_EXTERNAL_DIFF\s*=`, + `\bGIT_DIFF_OPTS\s*=`, + `\bBASH_ENV\s*=`, + `\bENV\s*=.*\bsh\b`, + + // Container escape + `/var/run/docker\.sock|docker\.(sock|socket)`, + `/proc/sys/(kernel|fs|net)/`, + `/sys/(kernel|fs|class|devices)/`, + + // Crypto mining + `\b(xmrig|cpuminer|minerd|cgminer|bfgminer|ethminer|nbminer|t-rex|phoenixminer|lolminer|gminer|claymore)\b`, + `stratum\+tcp://|stratum\+ssl://`, + + // Filter bypass (CVE-2025-66032) + `\bsed\b.*['"]/e\b`, + `\bsort\b.*--compress-program`, + `\bgit\b.*(--upload-pack|--receive-pack|--exec)=`, + `\b(rg|grep)\b.*--pre=`, + `\bman\b.*--html=`, + `\bhistory\b.*-[saw]\b`, + `\$\{[^}]*@[PpEeAaKk]\}`, + + // Network abuse / reconnaissance + `\b(nmap|masscan|zmap|rustscan)\b`, + `\b(ssh|scp|sftp)\b.*@`, + `\b(chisel|frp|ngrok|cloudflared|bore|localtunnel)\b`, + + // Persistence + `\bcrontab\b`, + `>\s*~/?\.(bashrc|bash_profile|profile|zshrc)`, + `\btee\b.*\.(bashrc|bash_profile|profile|zshrc)`, + + // Process manipulation + `\bkill\s+-9\s`, + `\b(killall|pkill)\b`, + + // Environment variable dumping + `^\s*env\s*$`, + `^\s*env\s*\|`, + `^\s*env\s*>\s`, + `\bprintenv\b`, + `^\s*(set|export\s+-p|declare\s+-x)\s*($|\|)`, + `\bcompgen\s+-e\b`, + `/proc/[^/]+/environ`, + `/proc/self/environ`, + `(?i)\bstrings\b.*/proc/`, +} diff --git a/internal/providers/claude_cli_hooks.go b/internal/providers/claude_cli_hooks.go new file mode 100644 index 00000000..83c986fc --- /dev/null +++ b/internal/providers/claude_cli_hooks.go @@ -0,0 +1,207 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// BuildCLIHooksConfig generates a Claude CLI settings file with PreToolUse hooks +// that enforce GoClaw's security policies (shell deny patterns, path restrictions). +// Returns settings file path and a cleanup function. +func BuildCLIHooksConfig(workspace string, restrictToWorkspace bool) (string, func(), error) { + tmpDir := filepath.Join(os.TempDir(), "goclaw-cli-hooks") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", nil, fmt.Errorf("create hooks dir: %w", err) + } + + id := uuid.New().String()[:8] + + // Write the hook script + hookScript := generateHookScript(workspace, restrictToWorkspace) + hookPath := filepath.Join(tmpDir, fmt.Sprintf("hook-%s.sh", id)) + if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil { + return "", nil, fmt.Errorf("write hook script: %w", err) + } + + // Write settings JSON + settings := generateSettingsJSON(hookPath) + settingsPath := filepath.Join(tmpDir, fmt.Sprintf("settings-%s.json", id)) + if err := os.WriteFile(settingsPath, settings, 0600); err != nil { + os.Remove(hookPath) + return "", nil, fmt.Errorf("write settings: %w", err) + } + + cleanup := func() { + os.Remove(hookPath) + os.Remove(settingsPath) + } + + return settingsPath, cleanup, nil +} + +// generateSettingsJSON creates Claude CLI settings with PreToolUse hooks. +func generateSettingsJSON(hookPath string) []byte { + settings := map[string]interface{}{ + "hooks": map[string]interface{}{ + "PreToolUse": []map[string]interface{}{ + { + "matcher": "Bash", + "hooks": []map[string]interface{}{ + {"type": "command", "command": hookPath}, + }, + }, + { + "matcher": "Write", + "hooks": []map[string]interface{}{ + {"type": "command", "command": hookPath}, + }, + }, + { + "matcher": "Edit", + "hooks": []map[string]interface{}{ + {"type": "command", "command": hookPath}, + }, + }, + { + "matcher": "Read", + "hooks": []map[string]interface{}{ + {"type": "command", "command": hookPath}, + }, + }, + }, + }, + } + + data, _ := json.MarshalIndent(settings, "", " ") + return data +} + +// generateHookScript creates a bash script that enforces GoClaw security policies. +func generateHookScript(workspace string, restrictToWorkspace bool) string { + var sb strings.Builder + + sb.WriteString(`#!/bin/bash +set -euo pipefail + +# GoClaw security hook for Claude CLI PreToolUse. +# Checks shell deny patterns and workspace path restrictions. + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') +TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}') + +allow() { + echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}' + exit 0 +} + +deny() { + local reason="$1" + echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$reason\"}}" + exit 0 +} + +`) + + // Shell deny patterns check + sb.WriteString(`# === Shell command deny patterns === +check_shell_deny() { + local cmd="$1" + local patterns=( +`) + + for _, p := range ShellDenyPatterns { + // Escape single quotes for bash + escaped := strings.ReplaceAll(p, `'`, `'\''`) + fmt.Fprintf(&sb, " '%s'\n", escaped) + } + + sb.WriteString(` ) + + for pattern in "${patterns[@]}"; do + if echo "$cmd" | grep -qE "$pattern" 2>/dev/null; then + deny "security: shell command blocked by deny pattern" + fi + done +} + +`) + + // Path restriction check + if restrictToWorkspace && workspace != "" { + // Escape workspace path for safe bash embedding (single quotes + quote escaping) + safeWorkspace := strings.ReplaceAll(workspace, `'`, `'\''`) + fmt.Fprintf(&sb, `# === Workspace path restriction === +WORKSPACE='%s' + +check_path_restriction() { + local file_path="$1" + # Resolve all paths (including relative) to absolute for proper checking + local resolved + resolved=$(realpath -m "$file_path" 2>/dev/null || echo "$file_path") + if [[ "$resolved" != "$WORKSPACE"* ]]; then + deny "security: path outside workspace boundary" + fi +} + +`, safeWorkspace) + } + + // Main dispatch + sb.WriteString(`# === Main === +case "$TOOL_NAME" in + Bash) + CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty') + if [ -n "$CMD" ]; then + check_shell_deny "$CMD" + fi + ;; + Write) + FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty') +`) + + if restrictToWorkspace && workspace != "" { + sb.WriteString(` if [ -n "$FILE_PATH" ]; then + check_path_restriction "$FILE_PATH" + fi +`) + } + + sb.WriteString(` ;; + Edit) + FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty') +`) + + if restrictToWorkspace && workspace != "" { + sb.WriteString(` if [ -n "$FILE_PATH" ]; then + check_path_restriction "$FILE_PATH" + fi +`) + } + + sb.WriteString(` ;; + Read) + FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty') +`) + + if restrictToWorkspace && workspace != "" { + sb.WriteString(` if [ -n "$FILE_PATH" ]; then + check_path_restriction "$FILE_PATH" + fi +`) + } + + sb.WriteString(` ;; +esac + +# Default: allow +allow +`) + + return sb.String() +} diff --git a/internal/providers/claude_cli_mcp.go b/internal/providers/claude_cli_mcp.go new file mode 100644 index 00000000..68db68fa --- /dev/null +++ b/internal/providers/claude_cli_mcp.go @@ -0,0 +1,110 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +// BuildCLIMCPConfig exports GoClaw's MCP server configs into a temp JSON file +// compatible with Claude CLI's --mcp-config flag. +// When gatewayAddr is non-empty, a "goclaw-bridge" entry is injected so CLI +// can call GoClaw's built-in tools via streamable-http MCP transport. +// Returns the file path and a cleanup function. Caller must call cleanup when done. +func BuildCLIMCPConfig(servers map[string]*config.MCPServerConfig, gatewayAddr string, gatewayToken ...string) (string, func(), error) { + mcpServers := make(map[string]interface{}, len(servers)+1) + + for name, srv := range servers { + if !srv.IsEnabled() { + continue + } + + entry := make(map[string]interface{}) + + switch srv.Transport { + case "stdio": + if srv.Command != "" { + entry["command"] = srv.Command + } + if len(srv.Args) > 0 { + entry["args"] = srv.Args + } + if len(srv.Env) > 0 { + entry["env"] = srv.Env + } + + case "sse": + if srv.URL != "" { + entry["url"] = srv.URL + entry["type"] = "sse" + } + if len(srv.Headers) > 0 { + entry["headers"] = srv.Headers + } + + case "streamable-http": + if srv.URL != "" { + entry["url"] = srv.URL + entry["type"] = "http" + } + if len(srv.Headers) > 0 { + entry["headers"] = srv.Headers + } + + default: + continue + } + + if len(entry) > 0 { + mcpServers[name] = entry + } + } + + // Inject GoClaw bridge entry so CLI can access built-in tools via MCP + if gatewayAddr != "" { + bridgeEntry := map[string]interface{}{ + "url": fmt.Sprintf("http://%s/mcp/bridge", gatewayAddr), + "type": "http", + } + if len(gatewayToken) > 0 && gatewayToken[0] != "" { + bridgeEntry["headers"] = map[string]string{ + "Authorization": "Bearer " + gatewayToken[0], + } + } + mcpServers["goclaw-bridge"] = bridgeEntry + } + + if len(mcpServers) == 0 { + return "", func() {}, nil + } + + cfg := map[string]interface{}{ + "mcpServers": mcpServers, + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", nil, fmt.Errorf("marshal mcp config: %w", err) + } + + // Write to a temp file with restricted permissions + tmpDir := filepath.Join(os.TempDir(), "goclaw-mcp-configs") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", nil, fmt.Errorf("create mcp config dir: %w", err) + } + + tmpFile := filepath.Join(tmpDir, fmt.Sprintf("mcp-%s.json", uuid.New().String()[:8])) + if err := os.WriteFile(tmpFile, data, 0600); err != nil { + return "", nil, fmt.Errorf("write mcp config: %w", err) + } + + cleanup := func() { + os.Remove(tmpFile) + } + + return tmpFile, cleanup, nil +} diff --git a/internal/providers/claude_cli_parse.go b/internal/providers/claude_cli_parse.go new file mode 100644 index 00000000..9caa07e1 --- /dev/null +++ b/internal/providers/claude_cli_parse.go @@ -0,0 +1,152 @@ +package providers + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// parseJSONResponse parses the CLI JSON output into a ChatResponse. +func parseJSONResponse(data []byte) (*ChatResponse, error) { + // Try parsing as JSON array first (CLI may output all events as a single array). + if resp := parseJSONArray(data); resp != nil { + return resp, nil + } + + // Fallback: CLI may output one JSON object per line. + for _, line := range bytes.Split(data, []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if resp := parseSingleJSONResult(line); resp != nil { + return resp, nil + } + } + + // Last resort: treat entire output as text response + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return nil, fmt.Errorf("claude-cli: empty response") + } + return &ChatResponse{ + Content: trimmed, + FinishReason: "stop", + }, nil +} + +// parseJSONArray tries to parse data as a JSON array of CLI events, extracting +// the "result" event's text content and "assistant" event's text blocks. +func parseJSONArray(data []byte) *ChatResponse { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || trimmed[0] != '[' { + return nil + } + + var events []json.RawMessage + if err := json.Unmarshal(trimmed, &events); err != nil { + return nil + } + + var resultText string + var assistantText strings.Builder + var usage *Usage + finishReason := "stop" + + for _, raw := range events { + var ev struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Result string `json:"result,omitempty"` + Message json.RawMessage `json:"message,omitempty"` + Usage *cliUsage `json:"usage,omitempty"` + } + if err := json.Unmarshal(raw, &ev); err != nil { + continue + } + + switch ev.Type { + case "result": + resultText = ev.Result + if ev.Subtype == "error" { + finishReason = "error" + } + if ev.Usage != nil { + usage = &Usage{ + PromptTokens: ev.Usage.InputTokens, + CompletionTokens: ev.Usage.OutputTokens, + TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens, + } + } + + case "assistant": + // Extract text from content blocks + if ev.Message != nil { + var msg cliStreamMsg + if err := json.Unmarshal(ev.Message, &msg); err == nil { + for _, block := range msg.Content { + if block.Type == "text" { + assistantText.WriteString(block.Text) + } + } + } + } + } + } + + // Prefer "result" text, fall back to concatenated assistant text blocks + content := resultText + if content == "" { + content = assistantText.String() + } + if content == "" { + return nil + } + + return &ChatResponse{ + Content: content, + FinishReason: finishReason, + Usage: usage, + } +} + +// parseSingleJSONResult tries to parse a single JSON line as a "result" event. +func parseSingleJSONResult(line []byte) *ChatResponse { + var resp cliJSONResponse + if err := json.Unmarshal(line, &resp); err != nil { + return nil + } + if resp.Type != "result" { + return nil + } + cr := &ChatResponse{ + Content: resp.Result, + FinishReason: "stop", + } + if resp.Subtype == "error" { + cr.FinishReason = "error" + } + if resp.Usage != nil { + cr.Usage = &Usage{ + PromptTokens: resp.Usage.InputTokens, + CompletionTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens, + } + } + return cr +} + +// extractStreamContent extracts text and thinking from a stream message. +func extractStreamContent(msg *cliStreamMsg) (text, thinking string) { + var textBuf, thinkBuf strings.Builder + for _, block := range msg.Content { + switch block.Type { + case "text": + textBuf.WriteString(block.Text) + case "thinking": + thinkBuf.WriteString(block.Thinking) + } + } + return textBuf.String(), thinkBuf.String() +} diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go new file mode 100644 index 00000000..dac803ce --- /dev/null +++ b/internal/providers/claude_cli_session.go @@ -0,0 +1,237 @@ +package providers + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// validCLIModels lists accepted model aliases for the Claude CLI. +var validCLIModels = map[string]bool{ + "sonnet": true, "opus": true, "haiku": true, +} + +// validateCLIModel checks if a model alias is supported by the Claude CLI. +func validateCLIModel(model string) error { + if !validCLIModels[model] { + return fmt.Errorf("claude-cli: unsupported model %q (valid: sonnet, opus, haiku)", model) + } + return nil +} + +// buildArgs constructs CLI arguments. +func (p *ClaudeCLIProvider) buildArgs(model, workDir string, cliSessionID uuid.UUID, outputFormat string, hasImages, disableTools bool) []string { + args := []string{ + "-p", + "--output-format", outputFormat, + "--model", model, + "--permission-mode", p.permMode, + "--verbose", + } + + if p.mcpConfigPath != "" { + args = append(args, "--mcp-config", p.mcpConfigPath) + } + + // Session persistence: check if CLI session file exists on disk. + // If exists → --resume (continue conversation). If not → --session-id (create new). + // Session files live at ~/.claude/projects//.jsonl + sid := cliSessionID.String() + if sessionFileExists(workDir, cliSessionID) { + args = append(args, "--resume", sid) + } else { + args = append(args, "--session-id", sid) + } + + if hasImages { + args = append(args, "--input-format", "stream-json") + } + + if disableTools { + // Summoner: disable all tools entirely via disallowedTools + args = append(args, "--disallowedTools", "Bash,Edit,Read,Write,Glob,Grep,WebFetch,WebSearch,TodoRead,TodoWrite,NotebookRead,NotebookEdit") + } else if p.mcpConfigPath != "" { + // Chat with MCP bridge: disable CLI built-in tools, only allow MCP bridge tools. + // This ensures all tool execution goes through GoClaw's controlled MCP bridge. + args = append(args, "--disallowedTools", "Bash,Edit,Read,Write,Glob,Grep,WebFetch,WebSearch,TodoRead,TodoWrite,NotebookRead,NotebookEdit") + } + + if p.hooksSettingsPath != "" { + args = append(args, "--settings", p.hooksSettingsPath) + } + + return args +} + +// ensureWorkDir creates and returns a stable work directory for the given session key. +func (p *ClaudeCLIProvider) ensureWorkDir(sessionKey string) string { + if sessionKey == "" { + sessionKey = "default" + } + // Sanitize session key for filesystem + safe := strings.NewReplacer(":", "-", "/", "-", "\\", "-").Replace(sessionKey) + dir := filepath.Join(p.baseWorkDir, safe) + + p.mu.Lock() + defer p.mu.Unlock() + + if err := os.MkdirAll(dir, 0755); err != nil { + slog.Warn("claude-cli: failed to create workdir", "dir", dir, "error", err) + return os.TempDir() + } + return dir +} + +// writeClaudeMD writes the system prompt to CLAUDE.md in the work directory. +// CLI reads this file automatically on every run (including --resume). +// Skips write if content is unchanged to avoid unnecessary disk I/O. +func (p *ClaudeCLIProvider) writeClaudeMD(workDir, systemPrompt string) { + path := filepath.Join(workDir, "CLAUDE.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("claude-cli: failed to write CLAUDE.md", "path", path, "error", err) + } +} + +// extractFromMessages extracts system prompt, last user message, and images from the messages array. +func extractFromMessages(msgs []Message) (systemPrompt, userMsg string, images []ImageContent) { + for _, m := range msgs { + if m.Role == "system" { + systemPrompt = m.Content + } + } + // Find last user message + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == "user" { + userMsg = msgs[i].Content + images = msgs[i].Images + break + } + } + return +} + +// extractDisableTools checks if disable_tools is set to true in Options. +func extractDisableTools(opts map[string]interface{}) bool { + if opts == nil { + return false + } + if v, ok := opts[OptDisableTools]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + +// extractSessionKey gets session_key from Options map. +func extractSessionKey(opts map[string]interface{}) string { + if opts == nil { + return "" + } + if v, ok := opts[OptSessionKey]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// defaultCLIWorkDir returns ~/.goclaw/cli-workspaces, falling back to temp dir. +func defaultCLIWorkDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "goclaw-cli-workspaces") + } + return filepath.Join(home, ".goclaw", "cli-workspaces") +} + +// deriveSessionUUID creates a deterministic UUID v5 from a session key string. +func deriveSessionUUID(sessionKey string) uuid.UUID { + if sessionKey == "" { + return uuid.New() // fallback: random + } + return uuid.NewSHA1(uuid.NameSpaceDNS, []byte(sessionKey)) +} + +// sessionFileExists checks if a Claude CLI session file exists for the given work directory. +// Claude CLI resolves symlinks (e.g. /var/folders → /private/var/folders on macOS) +// before encoding the path, so we must do the same. +func sessionFileExists(workDir string, sessionID uuid.UUID) bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + // Resolve symlinks to match CLI's path encoding (macOS: /var → /private/var) + resolved, err := filepath.EvalSymlinks(workDir) + if err != nil { + resolved = workDir + } + // Claude CLI stores sessions at: ~/.claude/projects//.jsonl + // CLI replaces path separators, "_", ".", and ":" with "-" in the path encoding. + // On Windows: C:\Users\foo → C--Users-foo (backslash + colon both become "-") + // On macOS/Linux: /home/foo → -home-foo (forward slash becomes "-") + encoded := strings.NewReplacer(string(filepath.Separator), "-", "_", "-", ".", "-", ":", "-").Replace(resolved) + sessionFile := filepath.Join(home, ".claude", "projects", encoded, sessionID.String()+".jsonl") + _, err = os.Stat(sessionFile) + return err == nil +} + +// buildStreamJSONInput creates stream-json stdin for vision (images + text). +func buildStreamJSONInput(text string, images []ImageContent) *bytes.Reader { + var contentBlocks []map[string]interface{} + + for _, img := range images { + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{ + "type": "base64", + "media_type": img.MimeType, + "data": img.Data, + }, + }) + } + + if text != "" { + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": text, + }) + } + + msg := map[string]interface{}{ + "type": "user", + "message": map[string]interface{}{ + "role": "user", + "content": contentBlocks, + }, + } + + data, _ := json.Marshal(msg) + return bytes.NewReader(data) +} + +// filterCLIEnv removes CLAUDE* env vars to prevent nested session conflicts. +func filterCLIEnv(environ []string) []string { + var filtered []string + for _, e := range environ { + key := e + if idx := strings.IndexByte(e, '='); idx >= 0 { + key = e[:idx] + } + // Filter out variables that could cause nested CLI conflicts + if strings.HasPrefix(key, "CLAUDE") { + continue + } + filtered = append(filtered, e) + } + return filtered +} diff --git a/internal/providers/claude_cli_types.go b/internal/providers/claude_cli_types.go new file mode 100644 index 00000000..b56a4dd9 --- /dev/null +++ b/internal/providers/claude_cli_types.go @@ -0,0 +1,44 @@ +package providers + +// Claude CLI JSON response types (internal). +// These map to the output of `claude -p --output-format json/stream-json`. + +// cliJSONResponse is the final result envelope from `--output-format json`. +type cliJSONResponse struct { + Type string `json:"type"` // "result" + Subtype string `json:"subtype"` // "success", "error" + Result string `json:"result"` // text response + SessionID string `json:"session_id"` // CLI session UUID + Model string `json:"model"` + CostUSD float64 `json:"cost_usd"` + Usage *cliUsage `json:"usage"` +} + +// cliUsage maps Claude CLI usage counters. +type cliUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// cliStreamEvent is a single line from `--output-format stream-json`. +type cliStreamEvent struct { + Type string `json:"type"` // "assistant", "result", "system" + Subtype string `json:"subtype,omitempty"` // "success", "error" + Message *cliStreamMsg `json:"message,omitempty"` // for type="assistant" + Result string `json:"result,omitempty"` // for type="result" + Model string `json:"model,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + Usage *cliUsage `json:"usage,omitempty"` +} + +// cliStreamMsg wraps content blocks inside an assistant message event. +type cliStreamMsg struct { + Content []cliContentBlock `json:"content"` +} + +// cliContentBlock is a single content block (text, thinking, tool_use). +type cliContentBlock struct { + Type string `json:"type"` // "text", "thinking", "tool_use", "tool_result" + Text string `json:"text,omitempty"` // for type="text" + Thinking string `json:"thinking,omitempty"` // for type="thinking" +} diff --git a/internal/providers/registry.go b/internal/providers/registry.go index d7ec0024..9b26ec59 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -2,6 +2,8 @@ package providers import ( "fmt" + "io" + "log/slog" "sync" ) @@ -18,17 +20,28 @@ func NewRegistry() *Registry { } // Register adds a provider to the registry. +// If a provider with the same name already exists, it is closed before replacement. func (r *Registry) Register(provider Provider) { r.mu.Lock() defer r.mu.Unlock() + if old, ok := r.providers[provider.Name()]; ok { + if c, ok := old.(io.Closer); ok { + c.Close() + } + } r.providers[provider.Name()] = provider } -// Unregister removes a provider from the registry. +// Unregister removes a provider from the registry, closing it if it implements io.Closer. func (r *Registry) Unregister(name string) { r.mu.Lock() defer r.mu.Unlock() - delete(r.providers, name) + if old, ok := r.providers[name]; ok { + if c, ok := old.(io.Closer); ok { + c.Close() + } + delete(r.providers, name) + } } // Get returns a provider by name. @@ -42,6 +55,19 @@ func (r *Registry) Get(name string) (Provider, error) { return p, nil } +// Close calls Close() on all providers that implement io.Closer. +func (r *Registry) Close() { + r.mu.RLock() + defer r.mu.RUnlock() + for name, p := range r.providers { + if c, ok := p.(io.Closer); ok { + if err := c.Close(); err != nil { + slog.Warn("provider close error", "name", name, "error", err) + } + } + } +} + // List returns all registered provider names. func (r *Registry) List() []string { r.mu.RLock() diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index aa63bb82..60582729 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -23,6 +23,7 @@ const ( ProviderDashScope = "dashscope" ProviderBailian = "bailian" ProviderChatGPTOAuth = "chatgpt_oauth" + ProviderClaudeCLI = "claude_cli" ) // ValidProviderTypes lists all accepted provider_type values. @@ -41,6 +42,7 @@ var ValidProviderTypes = map[string]bool{ ProviderDashScope: true, ProviderBailian: true, ProviderChatGPTOAuth: true, + ProviderClaudeCLI: true, } // LLMProviderData represents an LLM provider configuration. diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts index 666cd780..fe2054ed 100644 --- a/ui/web/src/api/ws-client.ts +++ b/ui/web/src/api/ws-client.ts @@ -99,13 +99,27 @@ export class WsClient { } /** - * Send an RPC call and wait for the response. + * Reset the timeout for a pending RPC call (e.g. when stream events arrive). */ - async call( + resetTimeout(requestId: string, timeoutMs: number): void { + const pending = this.pending.get(requestId); + if (!pending) return; + clearTimeout(pending.timeout); + pending.timeout = setTimeout(() => { + this.pending.delete(requestId); + pending.reject(new ApiError("AGENT_TIMEOUT", `timed out after ${timeoutMs}ms of inactivity`)); + }, timeoutMs); + } + + /** + * Send an RPC call and wait for the response. + * Returns { promise, requestId } so callers can reset the timeout on activity. + */ + callWithId( method: string, params?: Record, timeoutMs?: number, - ): Promise { + ): { promise: Promise; requestId: string } { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new ApiError("UNAVAILABLE", "WebSocket not connected"); } @@ -113,7 +127,7 @@ export class WsClient { const id = generateId(); const timeout = timeoutMs ?? this.defaultTimeout; - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new ApiError("AGENT_TIMEOUT", `${method} timed out after ${timeout}ms`)); @@ -129,6 +143,19 @@ export class WsClient { JSON.stringify({ type: "req", id, method, params }), ); }); + + return { promise, requestId: id }; + } + + /** + * Send an RPC call and wait for the response. + */ + async call( + method: string, + params?: Record, + timeoutMs?: number, + ): Promise { + return this.callWithId(method, params, timeoutMs).promise; } /** diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index 8c812eca..be4b51ff 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -28,7 +28,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { className={`max-w-[80%] rounded-lg px-4 py-2 ${ isUser ? "bg-primary text-primary-foreground" - : "bg-muted" + : "bg-card text-card-foreground border border-border shadow-sm" }`} > diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index ebb9e351..14974c1f 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -20,4 +20,5 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "perplexity", label: "Perplexity", apiBase: "https://api.perplexity.ai", placeholder: "" }, { value: "dashscope", label: "DashScope (Qwen)", apiBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", placeholder: "" }, { value: "bailian", label: "Bailian Coding", apiBase: "https://coding-intl.dashscope.aliyuncs.com/v1", placeholder: "" }, + { value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" }, ]; diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index ccb220aa..b17f14cc 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -41,6 +41,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const [agentType, setAgentType] = useState<"open" | "predefined">("open"); const [description, setDescription] = useState(""); const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); const enabledProviders = providers.filter((p) => p.enabled); @@ -72,6 +73,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const handleCreate = async () => { if (!agentKey.trim()) return; setLoading(true); + setError(""); try { await onCreate({ agent_key: agentKey.trim(), @@ -89,8 +91,9 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD setModel(""); setAgentType("open"); setDescription(""); - } catch { - // error handled upstream + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create agent"); } finally { setLoading(false); } @@ -251,6 +254,9 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD

)} + {error && ( +

{error}

+ )} + + {agent.is_default && ( +

+ The default agent cannot be deleted. +

+ )} + + + { + await onDelete(); + setConfirmOpen(false); + onDeleted(); + }} + /> + + ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index 90f4ab17..53488368 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -11,6 +11,7 @@ import { AgentFilesTab } from "./agent-files-tab"; import { AgentSharesTab } from "./agent-shares-tab"; import { AgentLinksTab } from "./agent-links-tab"; import { AgentSkillsTab } from "./agent-skills-tab"; +import { AgentDangerTab } from "./agent-danger-tab"; import { SummoningModal } from "../summoning-modal"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { DeferredSpinner } from "@/components/shared/loading-skeleton"; @@ -37,9 +38,9 @@ function agentSubtitle(agent: { display_name?: string; agent_key: string; id: st } export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { - const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh } = + const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, deleteAgent, refresh } = useAgentDetail(agentId); - const { deleteAgent } = useAgents(); + const { deleteAgent: deleteAgentById } = useAgents(); const [summoningOpen, setSummoningOpen] = useState(false); const [activeTab, setActiveTab] = useState("general"); const [deleteOpen, setDeleteOpen] = useState(false); @@ -130,6 +131,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { Shares Links Skills + Danger @@ -162,6 +164,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { + + + + @@ -182,7 +188,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { confirmLabel="Delete" variant="destructive" onConfirm={async () => { - await deleteAgent(agentId); + await deleteAgentById(agentId); setDeleteOpen(false); onBack(); }} diff --git a/ui/web/src/pages/agents/hooks/use-agent-detail.ts b/ui/web/src/pages/agents/hooks/use-agent-detail.ts index e73f4502..b6b13379 100644 --- a/ui/web/src/pages/agents/hooks/use-agent-detail.ts +++ b/ui/web/src/pages/agents/hooks/use-agent-detail.ts @@ -114,5 +114,11 @@ export function useAgentDetail(agentId: string | undefined) { await http.post(`/v1/agents/${agentId}/resummon`); }, [agentId, http]); - return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh: invalidate }; + const deleteAgent = useCallback(async () => { + if (!agentId) return; + await http.delete(`/v1/agents/${agentId}`); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }); + }, [agentId, http, queryClient]); + + return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, deleteAgent, refresh: invalidate }; } diff --git a/ui/web/src/pages/chat/hooks/use-chat-send.ts b/ui/web/src/pages/chat/hooks/use-chat-send.ts index 0db318d9..c2fffdb3 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-send.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-send.ts @@ -41,8 +41,6 @@ export function useChatSend({ onExpectRun(); try { - // The RPC response arrives after the run completes. - // Streaming events are captured by the event handler during the run. await ws.call<{ runId: string; content: string }>( Methods.CHAT_SEND, { @@ -51,7 +49,7 @@ export function useChatSend({ message: trimmed, stream: true, }, - 120_000, // 2 min timeout for long runs + 600_000, // 10 min timeout for long-running agent responses ); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send message"); diff --git a/ui/web/src/pages/providers/provider-cli-section.tsx b/ui/web/src/pages/providers/provider-cli-section.tsx new file mode 100644 index 00000000..dd0e60a3 --- /dev/null +++ b/ui/web/src/pages/providers/provider-cli-section.tsx @@ -0,0 +1,107 @@ +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2, AlertTriangle, RefreshCw } from "lucide-react"; +import { useHttp } from "@/hooks/use-ws"; + +interface CLIAuthStatus { + logged_in: boolean; + email?: string; + subscription_type?: string; + error?: string; +} + +export function CLISection({ open }: { open: boolean }) { + const http = useHttp(); + const [cliAuth, setCliAuth] = useState(null); + const [loading, setLoading] = useState(false); + + const checkAuth = useCallback(() => { + setLoading(true); + http + .get("/v1/providers/claude-cli/auth-status") + .then(setCliAuth) + .catch(() => setCliAuth({ logged_in: false, error: "Failed to check auth status" })) + .finally(() => setLoading(false)); + }, [http]); + + useEffect(() => { + if (open) { + checkAuth(); + } else { + setCliAuth(null); + } + }, [open, checkAuth]); + + return ( +
+

+ Claude CLI uses your local claude binary. No API key needed. +

+ {loading ? ( +
+ + Checking authentication... +
+ ) : cliAuth?.logged_in ? ( +
+
+
+ +

+ Authenticated as {cliAuth.email} + {cliAuth.subscription_type && ( + ({cliAuth.subscription_type}) + )} +

+
+ +
+
+ Switch account? +
+

Run on the server terminal:

+ claude auth logout && claude auth login +

Then click to re-check.

+
+
+
+ ) : cliAuth ? ( +
+
+
+ +

Not authenticated

+
+ +
+

+ Run on the server terminal: +

+ + claude auth login + + {cliAuth.error && ( +

{cliAuth.error}

+ )} +
+ ) : null} +
+ ); +} diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index 493eb5ea..69544c41 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Dialog, @@ -19,21 +19,21 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Loader2, ExternalLink, CheckCircle, ClipboardPaste } from "lucide-react"; import type { ProviderData, ProviderInput } from "./hooks/use-providers"; -import { useHttp } from "@/hooks/use-ws"; -import { toast } from "@/stores/use-toast-store"; import { slugify, isValidSlug } from "@/lib/slug"; import { PROVIDER_TYPES } from "@/constants/providers"; +import { OAuthSection } from "./provider-oauth-section"; +import { CLISection } from "./provider-cli-section"; interface ProviderFormDialogProps { open: boolean; onOpenChange: (open: boolean) => void; provider: ProviderData | null; // null = create mode onSubmit: (data: ProviderInput) => Promise; + existingProviders?: ProviderData[]; } -export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: ProviderFormDialogProps) { +export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, existingProviders = [] }: ProviderFormDialogProps) { const isEdit = !!provider; const queryClient = useQueryClient(); const [name, setName] = useState(""); @@ -45,7 +45,11 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + // Only one Claude CLI provider allowed per instance + const hasClaudeCLI = !isEdit && existingProviders.some((p) => p.provider_type === "claude_cli"); + const isOAuth = providerType === "chatgpt_oauth"; + const isCLI = providerType === "claude_cli"; useEffect(() => { if (open) { @@ -104,35 +108,22 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
{/* Provider type selector — always shown in create mode */} {!isEdit && ( -
- - -
+ { + setProviderType(v); + const preset = PROVIDER_TYPES.find((t) => t.value === v); + setApiBase(preset?.apiBase || ""); + if (v === "chatgpt_oauth") { + setName("openai-codex"); + setDisplayName("ChatGPT (OAuth)"); + } else { + if (name === "openai-codex") setName(""); + if (displayName === "ChatGPT (OAuth)") setDisplayName(""); + } + }} + /> )} {isOAuth ? ( @@ -192,31 +183,39 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
)} -
- - setApiBase(e.target.value)} - placeholder={PROVIDER_TYPES.find((t) => t.value === providerType)?.placeholder || PROVIDER_TYPES.find((t) => t.value === providerType)?.apiBase || "https://api.example.com/v1"} - /> -
+ {/* Claude CLI section */} + {isCLI && } -
- - setApiKey(e.target.value)} - placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."} - /> - {isEdit && apiKey === "***" && ( -

- API key is set. Clear and type a new value to change it. -

- )} -
+ {/* Standard provider fields (not shown for Claude CLI) */} + {!isCLI && ( + <> +
+ + setApiBase(e.target.value)} + placeholder={PROVIDER_TYPES.find((t) => t.value === providerType)?.placeholder || PROVIDER_TYPES.find((t) => t.value === providerType)?.apiBase || "https://api.example.com/v1"} + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."} + /> + {isEdit && apiKey === "***" && ( +

+ API key is set. Clear and type a new value to change it. +

+ )} +
+ + )}
@@ -246,228 +245,35 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P ); } -// --- OAuth section shown inside the dialog when chatgpt_oauth is selected --- - -interface OAuthStatus { - authenticated: boolean; - provider_name?: string; - error?: string; -} - -interface StartResponse { - auth_url?: string; - status?: string; -} - -function OAuthSection({ onSuccess }: { onSuccess: () => void }) { - const http = useHttp(); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [starting, setStarting] = useState(false); - const [waitingCallback, setWaitingCallback] = useState(false); - const [pasteUrl, setPasteUrl] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [justAuthenticated, setJustAuthenticated] = useState(false); - const [countdown, setCountdown] = useState(0); - const pollRef = useRef | null>(null); - const timeoutRef = useRef | null>(null); - - const stopPolling = () => { - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - }; - - const fetchStatus = useCallback(async () => { - try { - const res = await http.get("/v1/auth/openai/status"); - setStatus(res); - return res; - } catch { - setStatus(null); - return null; - } finally { - setLoading(false); - } - }, [http]); - - useEffect(() => { - fetchStatus(); - return stopPolling; - }, [fetchStatus]); - - // Countdown timer — auto-close dialog after auth success - useEffect(() => { - if (!justAuthenticated) return; - setCountdown(3); - const iv = setInterval(() => { - setCountdown((c) => { - if (c <= 1) { - clearInterval(iv); - onSuccess(); - return 0; - } - return c - 1; - }); - }, 1000); - return () => clearInterval(iv); - }, [justAuthenticated, onSuccess]); - - const showSuccess = () => { - setWaitingCallback(false); - setJustAuthenticated(true); - }; - - const handleStart = async () => { - setStarting(true); - try { - const res = await http.post("/v1/auth/openai/start"); - if (res.status === "already_authenticated") { - await fetchStatus(); - showSuccess(); - return; - } - if (res.auth_url) { - window.open(res.auth_url, "_blank", "noopener,noreferrer"); - setWaitingCallback(true); - setPasteUrl(""); - pollRef.current = setInterval(async () => { - const s = await fetchStatus(); - if (s?.authenticated) { - stopPolling(); - showSuccess(); - } - }, 2000); - timeoutRef.current = setTimeout(() => { - stopPolling(); - setWaitingCallback(false); - }, 6 * 60 * 1000); - } - } catch (err) { - toast.error("OAuth failed", err instanceof Error ? err.message : "Unknown error"); - } finally { - setStarting(false); - } - }; - - const handlePasteSubmit = async () => { - const url = pasteUrl.trim(); - if (!url) return; - setSubmitting(true); - try { - await http.post("/v1/auth/openai/callback", { redirect_url: url }); - stopPolling(); - setPasteUrl(""); - await fetchStatus(); - showSuccess(); - } catch (err) { - toast.error("Exchange failed", err instanceof Error ? err.message : "Invalid redirect URL"); - } finally { - setSubmitting(false); - } - }; - - const handleLogout = async () => { - try { - await http.post("/v1/auth/openai/logout"); - setStatus({ authenticated: false }); - toast.success("Logged out", "OpenAI OAuth token removed"); - } catch (err) { - toast.error("Logout failed", err instanceof Error ? err.message : "Unknown error"); - } - }; - - if (loading) { - return ( -
- Checking authentication status... -
- ); - } - - // Just authenticated — show success with countdown - if (justAuthenticated) { - return ( -
-
- -
-

Authentication successful!

-

- Provider openai-codex is - now active. Closing in {countdown}s... -

-
-
-
- ); - } - - // Already authenticated (opened dialog when already authed) - if (status?.authenticated) { - return ( -
-
- - - Authenticated — provider openai-codex active - -
-

- Use model prefix openai-codex/ in - agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically. -

- -
- ); - } +// --- Provider type select dropdown --- +function ProviderTypeSelect({ value, hasClaudeCLI, onChange }: { + value: string; + hasClaudeCLI: boolean; + onChange: (value: string) => void; +}) { return ( -
-

- Sign in with your ChatGPT account to use your subscription's models - without a separate API key. -

- {waitingCallback ? ( -
-
- - Waiting for authentication... Complete sign-in in the opened window. -
-
-

- Remote/VPS? After signing in, your browser will try to open{" "} - localhost:1455 and show a{" "} - "Can't reach this page" error. - That's normal — copy the full URL from the browser address bar and paste it below. -

-
- setPasteUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handlePasteSubmit()} - className="text-xs font-mono h-8" - /> - -
-
-
- ) : ( - - )} +
+ +
); } diff --git a/ui/web/src/pages/providers/provider-oauth-section.tsx b/ui/web/src/pages/providers/provider-oauth-section.tsx new file mode 100644 index 00000000..17b45b8f --- /dev/null +++ b/ui/web/src/pages/providers/provider-oauth-section.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2, ExternalLink, CheckCircle, ClipboardPaste } from "lucide-react"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; + +interface OAuthStatus { + authenticated: boolean; + provider_name?: string; + error?: string; +} + +interface StartResponse { + auth_url?: string; + status?: string; +} + +export function OAuthSection({ onSuccess }: { onSuccess: () => void }) { + const http = useHttp(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + const [waitingCallback, setWaitingCallback] = useState(false); + const [pasteUrl, setPasteUrl] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [justAuthenticated, setJustAuthenticated] = useState(false); + const [countdown, setCountdown] = useState(0); + const pollRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + + const stopPolling = () => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + }; + + const fetchStatus = useCallback(async () => { + try { + const res = await http.get("/v1/auth/openai/status"); + setStatus(res); + return res; + } catch { + setStatus(null); + return null; + } finally { + setLoading(false); + } + }, [http]); + + useEffect(() => { + fetchStatus(); + return stopPolling; + }, [fetchStatus]); + + // Countdown timer — auto-close dialog after auth success + useEffect(() => { + if (!justAuthenticated) return; + setCountdown(3); + const iv = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + clearInterval(iv); + onSuccess(); + return 0; + } + return c - 1; + }); + }, 1000); + return () => clearInterval(iv); + }, [justAuthenticated, onSuccess]); + + const showSuccess = () => { + setWaitingCallback(false); + setJustAuthenticated(true); + }; + + const handleStart = async () => { + setStarting(true); + try { + const res = await http.post("/v1/auth/openai/start"); + if (res.status === "already_authenticated") { + await fetchStatus(); + showSuccess(); + return; + } + if (res.auth_url) { + window.open(res.auth_url, "_blank", "noopener,noreferrer"); + setWaitingCallback(true); + setPasteUrl(""); + pollRef.current = setInterval(async () => { + const s = await fetchStatus(); + if (s?.authenticated) { + stopPolling(); + showSuccess(); + } + }, 2000); + timeoutRef.current = setTimeout(() => { + stopPolling(); + setWaitingCallback(false); + }, 6 * 60 * 1000); + } + } catch (err) { + toast.error("OAuth failed", err instanceof Error ? err.message : "Unknown error"); + } finally { + setStarting(false); + } + }; + + const handlePasteSubmit = async () => { + const url = pasteUrl.trim(); + if (!url) return; + setSubmitting(true); + try { + await http.post("/v1/auth/openai/callback", { redirect_url: url }); + stopPolling(); + setPasteUrl(""); + await fetchStatus(); + showSuccess(); + } catch (err) { + toast.error("Exchange failed", err instanceof Error ? err.message : "Invalid redirect URL"); + } finally { + setSubmitting(false); + } + }; + + const handleLogout = async () => { + try { + await http.post("/v1/auth/openai/logout"); + setStatus({ authenticated: false }); + toast.success("Logged out", "OpenAI OAuth token removed"); + } catch (err) { + toast.error("Logout failed", err instanceof Error ? err.message : "Unknown error"); + } + }; + + if (loading) { + return ( +
+ Checking authentication status... +
+ ); + } + + // Just authenticated — show success with countdown + if (justAuthenticated) { + return ( +
+
+ +
+

Authentication successful!

+

+ Provider openai-codex is + now active. Closing in {countdown}s... +

+
+
+
+ ); + } + + // Already authenticated (opened dialog when already authed) + if (status?.authenticated) { + return ( +
+
+ + + Authenticated — provider openai-codex active + +
+

+ Use model prefix openai-codex/ in + agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically. +

+ +
+ ); + } + + return ( +
+

+ Sign in with your ChatGPT account to use your subscription's models + without a separate API key. +

+ {waitingCallback ? ( +
+
+ + Waiting for authentication... Complete sign-in in the opened window. +
+
+

+ Remote/VPS? After signing in, your browser will try to open{" "} + localhost:1455 and show a{" "} + "Can't reach this page" error. + That's normal — copy the full URL from the browser address bar and paste it below. +

+
+ setPasteUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePasteSubmit()} + className="text-xs font-mono h-8" + /> + +
+
+
+ ) : ( + + )} +
+ ); +} diff --git a/ui/web/src/pages/providers/providers-page.tsx b/ui/web/src/pages/providers/providers-page.tsx index 54cb677d..b7755fd5 100644 --- a/ui/web/src/pages/providers/providers-page.tsx +++ b/ui/web/src/pages/providers/providers-page.tsx @@ -199,6 +199,7 @@ export function ProvidersPage() { onOpenChange={setFormOpen} provider={editProvider} onSubmit={editProvider ? handleEdit : handleCreate} + existingProviders={providers} />