From ff5a3e42206b2d79d3b4dd9109863cda3fb27a23 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 22 Mar 2026 23:29:03 +0800 Subject: [PATCH] refactor(sandbox): streamline connector configuration injection and cleanup - Removed the RegisterProxyConfigs function and its related logic from the sandbox initialization, simplifying the setup process. - Updated the GetComputer function to handle connector configuration injection directly within the ClaudeRunner.Stream method, ensuring a more cohesive approach to configuration management. - Introduced a new injectA2OConfig function to push connector configurations to the a2o proxy, enhancing flexibility and error handling. - Improved logging for connector configuration injection to provide better traceability during execution. --- agent/assistant/sandbox_v2.go | 5 -- agent/sandbox/v2/claude/command.go | 8 ++ agent/sandbox/v2/claude/runner.go | 96 +++++++++++++++++++++ agent/sandbox/v2/lifecycle.go | 131 +---------------------------- 4 files changed, 106 insertions(+), 134 deletions(-) diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go index 07454367..006caaa8 100644 --- a/agent/assistant/sandbox_v2.go +++ b/agent/assistant/sandbox_v2.go @@ -88,11 +88,6 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) } _ = identifier - // 3.5. Inject connector configs into a2o proxy via HTTP API. - if regErr := sandboxv2.RegisterProxyConfigs(stdCtx, computer); regErr != nil { - log.Printf("[sandbox/v2] RegisterProxyConfigs: %v (non-fatal)", regErr) - } - // 4. Get Runner. runner, err := sandboxv2.Get(cfg.Runner.Name) if err != nil { diff --git a/agent/sandbox/v2/claude/command.go b/agent/sandbox/v2/claude/command.go index 58c01b76..d15229a9 100644 --- a/agent/sandbox/v2/claude/command.go +++ b/agent/sandbox/v2/claude/command.go @@ -100,6 +100,14 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { connectorID := req.Connector.ID() env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID) env["ANTHROPIC_API_KEY"] = "dummy" + // Use a valid Anthropic model name to pass Claude CLI's local + // validation. The a2o proxy ignores this and substitutes the + // real backend model from its connector config. + env["ANTHROPIC_MODEL"] = "claude-sonnet-4-6" + env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = "claude-sonnet-4-6" + env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "claude-sonnet-4-6" + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-sonnet-4-6" + env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6" } if thinking, ok := setting["thinking"].(map[string]interface{}); ok { diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index dbfe2bfe..44c745b7 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -2,9 +2,13 @@ package claude import ( "context" + "encoding/json" "fmt" "os" + "strings" + "github.com/yaoapp/gou/connector" + "github.com/yaoapp/kun/log" agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/sandbox/v2/types" @@ -82,6 +86,11 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han p := resolvePlatform(computer) + // Inject connector config into a2o proxy (best-effort, errors ignored). + if req.Connector != nil && req.Connector.Is(connector.OPENAI) { + injectA2OConfig(ctx, computer, req.Connector) + } + if req.ChatID != "" { if ws := computer.Workplace(); ws != nil { processed, err := prepareAttachments(ctx, req.Messages, req.ChatID, ws) @@ -130,3 +139,90 @@ func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) err return nil } + +type a2oConnectorConfig struct { + Backend string `json:"backend"` + Model string `json:"model"` + APIKey string `json:"api_key"` + Options map[string]interface{} `json:"options,omitempty"` +} + +func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig { + settings := conn.Setting() + if settings == nil { + return nil + } + + cfg := &a2oConnectorConfig{} + + if host, ok := settings["host"].(string); ok && host != "" { + cfg.Backend = connector.BuildAPIURL(host, "/chat/completions") + } else if proxy, ok := settings["proxy"].(string); ok && proxy != "" { + cfg.Backend = connector.BuildAPIURL(proxy, "/chat/completions") + } + if model, ok := settings["model"].(string); ok && model != "" { + cfg.Model = model + } + if key, ok := settings["key"].(string); ok && key != "" { + cfg.APIKey = key + } + + extra := make(map[string]interface{}) + for k, v := range settings { + switch k { + case "host", "model", "key", "proxy", "type": + continue + default: + extra[k] = v + } + } + if len(extra) > 0 { + cfg.Options = extra + } + + if cfg.Backend == "" { + return nil + } + return cfg +} + +// injectA2OConfig pushes the connector config to the a2o proxy. +// For box (Linux container): uses sh pipe since Docker exec stdin may not work. +// For host: uses WithStdin which works reliably on all platforms. +// Best-effort: errors are logged and ignored. +func injectA2OConfig(ctx context.Context, computer infra.Computer, conn connector.Connector) { + cfg := buildSingleA2OConfig(conn) + if cfg == nil { + log.Trace("[claude] injectA2OConfig: no valid config for connector %s", conn.ID()) + return + } + + data, err := json.Marshal(cfg) + if err != nil { + log.Trace("[claude] injectA2OConfig: marshal error: %v", err) + return + } + + connID := conn.ID() + var result *infra.ExecResult + + info := computer.ComputerInfo() + if info.Kind == "host" { + result, err = computer.Exec(ctx, []string{"tai", "a2o", "config", "put", connID}, infra.WithStdin(data)) + } else { + escaped := strings.ReplaceAll(string(data), "'", "'\\''") + script := fmt.Sprintf("echo '%s' | tai a2o config put %s", escaped, connID) + result, err = computer.Exec(ctx, []string{"sh", "-c", script}) + } + + if err != nil { + log.Trace("[claude] injectA2OConfig: exec error (ignored): %v", err) + return + } + if result.ExitCode != 0 { + log.Trace("[claude] injectA2OConfig: exit %d stderr=%s (ignored)", result.ExitCode, result.Stderr) + return + } + + log.Trace("[claude] injectA2OConfig: connector=%s injected ok", connID) +} diff --git a/agent/sandbox/v2/lifecycle.go b/agent/sandbox/v2/lifecycle.go index 02aaee14..b58ce398 100644 --- a/agent/sandbox/v2/lifecycle.go +++ b/agent/sandbox/v2/lifecycle.go @@ -1,18 +1,13 @@ package sandboxv2 import ( - "bytes" "context" "crypto/rand" "encoding/hex" - "encoding/json" "fmt" mathrand "math/rand" - "net/http" "strings" - "time" - "github.com/yaoapp/gou/connector" "github.com/yaoapp/kun/log" agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/sandbox/v2/types" @@ -100,8 +95,8 @@ func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager } // GetComputer obtains or creates a Computer for the current request. -// Connector config injection is handled via RegisterProxyConfigs after -// the Computer is obtained. +// Connector config is injected per-execution inside ClaudeRunner.Stream +// via "tai a2o config put". // Returns the Computer, the resolved identifier, and any error. func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (infra.Computer, string, error) { ownerID := resolveOwnerID(ctx) @@ -388,128 +383,6 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error return candidates[mathrand.Intn(len(candidates))], nil } -const defaultA2OPort = 3099 - -// a2oConnectorConfig mirrors the tai/a2o ConnectorConfig struct for JSON serialization. -type a2oConnectorConfig struct { - Backend string `json:"backend"` - Model string `json:"model"` - APIKey string `json:"api_key"` - Options map[string]interface{} `json:"options,omitempty"` -} - -// RegisterProxyConfigs injects all OpenAI-compatible connector configs into -// the a2o proxy running inside the container via POST /config. -// For host mode it uses Go's net/http directly; for box mode it uses computer.Exec. -func RegisterProxyConfigs(ctx context.Context, computer infra.Computer) error { - configs := buildA2OConfigs() - if len(configs) == 0 { - log.Trace("[sandbox/v2] RegisterProxyConfigs: no OpenAI connectors to register") - return nil - } - - data, err := json.Marshal(configs) - if err != nil { - return fmt.Errorf("marshal connector configs: %w", err) - } - - info := computer.ComputerInfo() - a2oURL := fmt.Sprintf("http://127.0.0.1:%d", defaultA2OPort) - - if info.Kind == "host" { - return registerProxyConfigsHTTP(a2oURL, data) - } - return registerProxyConfigsExec(ctx, computer, data) -} - -func buildA2OConfigs() map[string]*a2oConnectorConfig { - configs := make(map[string]*a2oConnectorConfig) - for id, conn := range connector.Connectors { - if !conn.Is(connector.OPENAI) { - continue - } - settings := conn.Setting() - if settings == nil { - continue - } - - cfg := &a2oConnectorConfig{} - if host, ok := settings["host"].(string); ok && host != "" { - cfg.Backend = connector.BuildAPIURL(host, "/chat/completions") - } - if model, ok := settings["model"].(string); ok && model != "" { - cfg.Model = model - } - if key, ok := settings["key"].(string); ok && key != "" { - cfg.APIKey = key - } - - extra := make(map[string]interface{}) - for k, v := range settings { - switch k { - case "host", "model", "key", "proxy", "type": - continue - default: - extra[k] = v - } - } - if len(extra) > 0 { - cfg.Options = extra - } - - if cfg.Backend != "" { - configs[id] = cfg - } - } - return configs -} - -func registerProxyConfigsHTTP(a2oURL string, data []byte) error { - client := &http.Client{Timeout: 10 * time.Second} - configURL := a2oURL + "/config" - - for attempt := 0; attempt < 10; attempt++ { - resp, err := client.Get(a2oURL + "/health") - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - break - } - } - time.Sleep(500 * time.Millisecond) - } - - resp, err := client.Post(configURL, "application/json", bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("POST %s: %w", configURL, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("POST %s: status %d", configURL, resp.StatusCode) - } - - log.Trace("[sandbox/v2] RegisterProxyConfigs(host): injected %d bytes", len(data)) - return nil -} - -func registerProxyConfigsExec(ctx context.Context, computer infra.Computer, data []byte) error { - escaped := strings.ReplaceAll(string(data), "'", "'\\''") - healthCmd := fmt.Sprintf("for i in $(seq 1 20); do wget -qO- http://127.0.0.1:%d/health >/dev/null 2>&1 && break; sleep 0.5; done", defaultA2OPort) - postCmd := fmt.Sprintf("echo '%s' | wget -qO- --post-data=@- --header='Content-Type: application/json' http://127.0.0.1:%d/config", escaped, defaultA2OPort) - script := healthCmd + " && " + postCmd - - result, err := computer.Exec(ctx, []string{"sh", "-c", script}) - if err != nil { - return fmt.Errorf("exec register proxy configs: %w", err) - } - if result.ExitCode != 0 { - return fmt.Errorf("register proxy configs: exit %d, stderr=%s", result.ExitCode, result.Stderr) - } - - log.Trace("[sandbox/v2] RegisterProxyConfigs(box): injected %d bytes, stdout=%s", len(data), result.Stdout) - return nil -} - func randomID() string { b := make([]byte, 8) _, _ = rand.Read(b)