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.
This commit is contained in:
Max 2026-03-22 23:29:03 +08:00
parent 5465fb2718
commit ff5a3e4220
4 changed files with 106 additions and 134 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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)