feat(providers): add Claude CLI provider with MCP bridge (#61)

Add Claude CLI as an LLM provider (subscription-based, no API key needed).
The CLI manages session history, tool execution, and context while GoClaw
forwards messages and streams responses.

Key features:
- Claude CLI provider with session persistence (--resume)
- MCP bridge server exposing GoClaw tools to CLI via streamable-http
- Security hooks (shell deny patterns, workspace path restrictions)
- Per-session mutex preventing concurrent CLI calls
- Onboard wizard for Claude CLI setup and auth verification
- Web UI for adding/managing Claude CLI provider with auth status
- Provider registry Close() for proper shutdown cleanup

Security:
- CLI path validation (only "claude" or absolute paths from DB)
- Token auth middleware for MCP bridge endpoint
- Shell injection prevention in hook scripts (single-quoted paths)
- Relative path resolution before workspace boundary checks
- Resource leak prevention on provider replace/unregister

Co-authored-by: nhokboo <nhokboo@users.noreply.github.com>
This commit is contained in:
viettranxandnhokboo committed 2026-03-07 02:06:39 +07:00
1 parent 39e906e5d4
commit b2c4d543aa
39 files changed
+2304 -324

No files matched your search

+7 -2
View File
@@ -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()
+2 -2
View File
@@ -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 {
+76 -2
View File
@@ -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 {
+87 -2
View File
@@ -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 = ""
+18
View File
@@ -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()
}
+8
View File
@@ -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) {
+1
View File
@@ -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" {
+25 -15
View File
@@ -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.
+5
View File
@@ -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)
+26
View File
@@ -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()
+12 -1
View File
@@ -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
}
+16
View File
@@ -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)
+47
View File
@@ -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 {
+48 -3
View File
@@ -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()})
+21 -3
View File
@@ -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)
+98
View File
@@ -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
}
}
+124
View File
@@ -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
}
+40
View File
@@ -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
}
+215
View File
@@ -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
}
@@ -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/`,
}
+207
View File
@@ -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()
}
+110
View File
@@ -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
}
+152
View File
@@ -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()
}
+237
View File
@@ -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/<sanitized-workdir>/<uuid>.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/<encoded-path>/<session-id>.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
}
+44
View File
@@ -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"
}
+28 -2
View File
@@ -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()
+2
View File
@@ -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.
+31 -4
View File
@@ -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<T = unknown>(
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<T = unknown>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
): Promise<T> {
): { promise: Promise<T>; 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<T>((resolve, reject) => {
const promise = new Promise<T>((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<T = unknown>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
): Promise<T> {
return this.callWithId<T>(method, params, timeoutMs).promise;
}
/**
@@ -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"
}`}
>
<MessageContent content={message.content} role={message.role} />
+1
View File
@@ -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: "" },
];
@@ -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
</p>
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
@@ -0,0 +1,59 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import type { AgentData } from "@/types/agent";
interface AgentDangerTabProps {
agent: AgentData;
onDelete: () => Promise<void>;
onDeleted: () => void;
}
export function AgentDangerTab({ agent, onDelete, onDeleted }: AgentDangerTabProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<div className="space-y-6">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-destructive">Delete Agent</h3>
<p className="mt-1 text-sm text-muted-foreground">
Permanently delete this agent, all its context files, sessions, and configuration.
This action cannot be undone.
</p>
</div>
<Button
variant="destructive"
className="shrink-0 gap-2"
disabled={agent.is_default}
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="h-4 w-4" />
Delete Agent
</Button>
</div>
{agent.is_default && (
<p className="mt-3 text-xs text-muted-foreground">
The default agent cannot be deleted.
</p>
)}
</div>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete Agent"
description={`Are you sure you want to delete "${agent.display_name || agent.agent_key}"? All context files, sessions, and configuration will be permanently removed.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={async () => {
await onDelete();
setConfirmOpen(false);
onDeleted();
}}
/>
</div>
);
}
@@ -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) {
<TabsTrigger value="shares">Shares</TabsTrigger>
<TabsTrigger value="links">Links</TabsTrigger>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="danger" className="text-destructive data-[state=active]:text-destructive">Danger</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-4">
@@ -162,6 +164,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<TabsContent value="skills" className="mt-4">
<AgentSkillsTab agentId={agentId} />
</TabsContent>
<TabsContent value="danger" className="mt-4">
<AgentDangerTab agent={agent} onDelete={deleteAgent} onDeleted={onBack} />
</TabsContent>
</Tabs>
</div>
@@ -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();
}}
@@ -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 };
}
+1 -3
View File
@@ -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");
@@ -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<CLIAuthStatus | null>(null);
const [loading, setLoading] = useState(false);
const checkAuth = useCallback(() => {
setLoading(true);
http
.get<CLIAuthStatus>("/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 (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Claude CLI uses your local <code className="rounded bg-muted px-1 py-0.5">claude</code> binary. No API key needed.
</p>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Checking authentication...
</div>
) : cliAuth?.logged_in ? (
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-green-200 bg-green-50 px-3 py-2 dark:border-green-800 dark:bg-green-950">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<p className="text-sm text-green-700 dark:text-green-300">
Authenticated as <strong>{cliAuth.email}</strong>
{cliAuth.subscription_type && (
<span className="ml-1 text-xs opacity-75">({cliAuth.subscription_type})</span>
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-green-700 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
onClick={checkAuth}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer hover:text-foreground">Switch account?</summary>
<div className="mt-1.5 space-y-1 rounded-md border bg-muted/50 px-3 py-2">
<p>Run on the server terminal:</p>
<code className="block rounded bg-muted px-2 py-1 font-mono">claude auth logout && claude auth login</code>
<p>Then click <RefreshCw className="inline h-3 w-3" /> to re-check.</p>
</div>
</details>
</div>
) : cliAuth ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">Not authenticated</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-amber-700 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300"
onClick={checkAuth}
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
<span className="text-xs">Re-check</span>
</Button>
</div>
<p className="mt-1 text-sm text-amber-600 dark:text-amber-400">
Run on the server terminal:
</p>
<code className="mt-1 block rounded bg-amber-100 px-2 py-1 text-xs font-mono dark:bg-amber-900 dark:text-amber-300">
claude auth login
</code>
{cliAuth.error && (
<p className="mt-1 text-xs text-amber-500">{cliAuth.error}</p>
)}
</div>
) : null}
</div>
);
}
@@ -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<unknown>;
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
<div className="space-y-4 py-4 overflow-y-auto min-h-0">
{/* Provider type selector — always shown in create mode */}
{!isEdit && (
<div className="space-y-2">
<Label>Provider Type *</Label>
<Select
value={providerType}
onValueChange={(v) => {
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("");
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ProviderTypeSelect
value={providerType}
hasClaudeCLI={hasClaudeCLI}
onChange={(v) => {
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
</div>
)}
<div className="space-y-2">
<Label htmlFor="apiBase">API Base URL</Label>
<Input
id="apiBase"
value={apiBase}
onChange={(e) => 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"}
/>
</div>
{/* Claude CLI section */}
{isCLI && <CLISection open={open} />}
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."}
/>
{isEdit && apiKey === "***" && (
<p className="text-xs text-muted-foreground">
API key is set. Clear and type a new value to change it.
</p>
)}
</div>
{/* Standard provider fields (not shown for Claude CLI) */}
{!isCLI && (
<>
<div className="space-y-2">
<Label htmlFor="apiBase">API Base URL</Label>
<Input
id="apiBase"
value={apiBase}
onChange={(e) => 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"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."}
/>
{isEdit && apiKey === "***" && (
<p className="text-xs text-muted-foreground">
API key is set. Clear and type a new value to change it.
</p>
)}
</div>
</>
)}
<div className="flex items-center justify-between">
<Label htmlFor="enabled">Enabled</Label>
@@ -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<OAuthStatus | null>(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<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<OAuthStatus>("/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<StartResponse>("/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 (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Checking authentication status...
</div>
);
}
// Just authenticated — show success with countdown
if (justAuthenticated) {
return (
<div className="space-y-3 py-2">
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/5 px-4 py-3 text-sm text-green-700 dark:text-green-400">
<CheckCircle className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Authentication successful!</p>
<p className="text-xs mt-0.5 opacity-80">
Provider <code className="rounded bg-muted px-1 font-mono text-xs">openai-codex</code> is
now active. Closing in {countdown}s...
</p>
</div>
</div>
</div>
);
}
// Already authenticated (opened dialog when already authed)
if (status?.authenticated) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2 text-sm text-green-700 dark:text-green-400">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>
Authenticated — provider <code className="rounded bg-muted px-1 font-mono text-xs">openai-codex</code> active
</span>
</div>
<p className="text-xs text-muted-foreground">
Use model prefix <code className="rounded bg-muted px-1 font-mono">openai-codex/</code> in
agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically.
</p>
<Button variant="outline" size="sm" onClick={handleLogout} className="gap-1.5">
Remove Token
</Button>
</div>
);
}
// --- Provider type select dropdown ---
function ProviderTypeSelect({ value, hasClaudeCLI, onChange }: {
value: string;
hasClaudeCLI: boolean;
onChange: (value: string) => void;
}) {
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Sign in with your ChatGPT account to use your subscription's models
without a separate API key.
</p>
{waitingCallback ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-sm text-blue-700 dark:text-blue-400">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
<span>Waiting for authentication... Complete sign-in in the opened window.</span>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<p className="text-xs text-amber-700 dark:text-amber-400">
<strong>Remote/VPS?</strong> After signing in, your browser will try to open{" "}
<code className="text-xs">localhost:1455</code> and show a{" "}
<strong>&quot;Can&apos;t reach this page&quot;</strong> error.
That&apos;s normal — <strong>copy the full URL from the browser address bar</strong> and paste it below.
</p>
<div className="flex gap-2">
<Input
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handlePasteSubmit()}
className="text-xs font-mono h-8"
/>
<Button
size="sm"
onClick={handlePasteSubmit}
disabled={submitting || !pasteUrl.trim()}
className="gap-1.5 shrink-0 h-8"
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ClipboardPaste className="h-3.5 w-3.5" />}
Submit
</Button>
</div>
</div>
</div>
) : (
<Button size="sm" onClick={handleStart} disabled={starting} className="gap-1.5">
{starting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ExternalLink className="h-3.5 w-3.5" />}
{starting ? "Starting..." : "Sign in with ChatGPT"}
</Button>
)}
<div className="space-y-2">
<Label>Provider Type *</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.map((t) => (
<SelectItem
key={t.value}
value={t.value}
disabled={t.value === "claude_cli" && hasClaudeCLI}
>
{t.label}
{t.value === "claude_cli" && hasClaudeCLI && (
<span className="ml-1 text-xs opacity-60">(already added)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -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<OAuthStatus | null>(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<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<OAuthStatus>("/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<StartResponse>("/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 (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Checking authentication status...
</div>
);
}
// Just authenticated — show success with countdown
if (justAuthenticated) {
return (
<div className="space-y-3 py-2">
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/5 px-4 py-3 text-sm text-green-700 dark:text-green-400">
<CheckCircle className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Authentication successful!</p>
<p className="text-xs mt-0.5 opacity-80">
Provider <code className="rounded bg-muted px-1 font-mono text-xs">openai-codex</code> is
now active. Closing in {countdown}s...
</p>
</div>
</div>
</div>
);
}
// Already authenticated (opened dialog when already authed)
if (status?.authenticated) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2 text-sm text-green-700 dark:text-green-400">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>
Authenticated — provider <code className="rounded bg-muted px-1 font-mono text-xs">openai-codex</code> active
</span>
</div>
<p className="text-xs text-muted-foreground">
Use model prefix <code className="rounded bg-muted px-1 font-mono">openai-codex/</code> in
agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically.
</p>
<Button variant="outline" size="sm" onClick={handleLogout} className="gap-1.5">
Remove Token
</Button>
</div>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Sign in with your ChatGPT account to use your subscription's models
without a separate API key.
</p>
{waitingCallback ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-sm text-blue-700 dark:text-blue-400">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
<span>Waiting for authentication... Complete sign-in in the opened window.</span>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<p className="text-xs text-amber-700 dark:text-amber-400">
<strong>Remote/VPS?</strong> After signing in, your browser will try to open{" "}
<code className="text-xs">localhost:1455</code> and show a{" "}
<strong>&quot;Can&apos;t reach this page&quot;</strong> error.
That&apos;s normal — <strong>copy the full URL from the browser address bar</strong> and paste it below.
</p>
<div className="flex gap-2">
<Input
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handlePasteSubmit()}
className="text-xs font-mono h-8"
/>
<Button
size="sm"
onClick={handlePasteSubmit}
disabled={submitting || !pasteUrl.trim()}
className="gap-1.5 shrink-0 h-8"
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ClipboardPaste className="h-3.5 w-3.5" />}
Submit
</Button>
</div>
</div>
</div>
) : (
<Button size="sm" onClick={handleStart} disabled={starting} className="gap-1.5">
{starting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ExternalLink className="h-3.5 w-3.5" />}
{starting ? "Starting..." : "Sign in with ChatGPT"}
</Button>
)}
</div>
);
}
@@ -199,6 +199,7 @@ export function ProvidersPage() {
onOpenChange={setFormOpen}
provider={editProvider}
onSubmit={editProvider ? handleEdit : handleCreate}
existingProviders={providers}
/>
<ConfirmDialog