mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
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:
1 parent
39e906e5d4
commit
b2c4d543aa
39 files changed
+2304
-324
No files matched your search
+7
-2
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/`,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>"Can't reach this page"</strong> error.
|
||||
That'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>"Can't reach this page"</strong> error.
|
||||
That'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
|
||||
|
||||
Reference in new issue
Block a user