From 7693560d53aeafde4a7add6741a35665c1c3df44 Mon Sep 17 00:00:00 2001 From: rjckkkkk <59609580+rjckkkkk@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:35:22 +0000 Subject: [PATCH] openclaw sync: preflight-probe the AIMA proxy and warn loudly if unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aima openclaw sync` writes an OpenClaw provider that points the chat data plane at the `aima serve` proxy (default :6188). Two real-world integration failures both surface inside OpenClaw as an opaque "connection error/timeout" that looks nothing like a config problem: 1. Only the MCP control plane (`aima mcp`) is wired, so nothing is serving :6188 — the provider has no listener to reach. 2. An HTTP_PROXY/HTTPS_PROXY env intercepts loopback; OpenClaw runs on Node and routes 127.0.0.1 through the proxy, so it times out even though curl (which bypasses the proxy for localhost) works. Probe the proxy address during sync with two requests — one direct, one honoring the env proxy — so the warning names the actual failure mode and the fix. Surface it both as a loud slog.Warn and as proxyReachable/proxyWarning on the sync result (so software embedding AIMA can show it in their own UI). The probe never blocks the config write; it only adds a diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/openclaw/sync.go | 70 ++++++++++++++++++++++++ internal/openclaw/sync_preflight_test.go | 42 ++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/openclaw/sync_preflight_test.go diff --git a/internal/openclaw/sync.go b/internal/openclaw/sync.go index 2122e0fd..b60e22fa 100644 --- a/internal/openclaw/sync.go +++ b/internal/openclaw/sync.go @@ -6,10 +6,13 @@ import ( "fmt" "io/fs" "log/slog" + "net/http" + "net/url" "os" "path/filepath" "sort" "strings" + "time" "github.com/jguan/aima/internal/openclaw/plugins" "github.com/jguan/aima/internal/openclaw/skills" @@ -35,6 +38,8 @@ type SyncResult struct { MCPServer *MCPServerEntry `json:"mcpServer,omitempty"` ProxyAddr string `json:"proxyAddr"` APIKey string `json:"apiKey,omitempty"` + ProxyReachable bool `json:"proxyReachable"` + ProxyWarning string `json:"proxyWarning,omitempty"` ConfigPath string `json:"configPath"` ConfigExists bool `json:"configExists"` Written bool `json:"written"` @@ -86,6 +91,19 @@ func Sync(ctx context.Context, deps *Deps, dryRun bool) (*SyncResult, error) { ConfigPath: deps.ConfigPath, MCPServer: desiredMCPServer(deps), } + + // Preflight: the provider we write points OpenClaw's chat data plane at + // deps.ProxyAddr (the `aima serve` proxy, default :6188). If serve is not + // listening there — or an HTTP_PROXY env intercepts loopback — OpenClaw + // fails every request with a connection error/timeout that looks nothing + // like a config problem. Probe it now and surface a loud, actionable + // warning instead of letting the partner discover it inside OpenClaw. + result.ProxyReachable, result.ProxyWarning = probeProxyReachable(ctx, deps.ProxyAddr, deps.proxyAPIKey()) + if !result.ProxyReachable { + slog.Warn("openclaw sync: AIMA proxy preflight failed", + "proxy", deps.ProxyAddr, "detail", result.ProxyWarning) + } + var ttsIDs []string for _, b := range backends { @@ -190,6 +208,58 @@ func Sync(ctx context.Context, deps *Deps, dryRun bool) (*SyncResult, error) { return result, nil } +// probeProxyReachable checks whether the AIMA serve proxy is actually reachable +// at proxyAddr — the address the OpenClaw provider written by this sync points at +// for the chat data plane. It runs two probes so the warning can name the real +// failure mode: +// +// 1. direct (no proxy): is `aima serve` listening at all? The MCP control plane +// (`aima mcp`) does NOT open this port, so wiring only the MCP server leaves +// nothing serving :6188 and every OpenClaw request dies with a connection error. +// 2. env-proxy: mirrors clients that honor HTTP_PROXY/HTTPS_PROXY (OpenClaw runs on +// Node, which does). If the direct probe succeeds but this one fails, an env +// proxy is intercepting loopback and OpenClaw will time out even though curl +// (which bypasses the proxy for localhost) works. +// +// Returns (reachable, warning). reachable is true only when both probes succeed. +// A malformed/empty address is treated as reachable (no false alarm). +func probeProxyReachable(ctx context.Context, proxyAddr, apiKey string) (bool, string) { + addr := strings.TrimSpace(proxyAddr) + if addr == "" { + return true, "" + } + probeURL := strings.TrimRight(addr, "/") + "/models" + + probe := func(proxyFn func(*http.Request) (*url.URL, error)) error { + reqCtx, cancel := context.WithTimeout(ctx, 2500*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, probeURL, nil) + if err != nil { + return nil // can't build probe — don't raise a false alarm + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + client := &http.Client{Transport: &http.Transport{Proxy: proxyFn}} + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + return err + } + // Any HTTP status (200/401/404/...) proves serve is listening. + resp.Body.Close() + return nil + } + + if err := probe(nil); err != nil { + return false, fmt.Sprintf("AIMA proxy not reachable at %s — nothing is serving this port, so OpenClaw will fail every request with a connection error/timeout. Start the data plane with `aima serve` (note: the MCP server `aima mcp` does NOT open this port). Underlying error: %v", addr, err) + } + if err := probe(http.ProxyFromEnvironment); err != nil { + return false, fmt.Sprintf("AIMA proxy at %s is reachable directly but NOT through your HTTP_PROXY/HTTPS_PROXY environment — OpenClaw runs on Node and routes loopback through that proxy, so it will time out even though curl works. Set NO_PROXY=127.0.0.1,localhost,::1 (and lowercase no_proxy) for the OpenClaw process, or unset the proxy. Underlying error: %v", addr, err) + } + return true, "" +} + func desiredMCPServer(deps *Deps) *MCPServerEntry { if deps == nil { return nil diff --git a/internal/openclaw/sync_preflight_test.go b/internal/openclaw/sync_preflight_test.go new file mode 100644 index 00000000..223029ce --- /dev/null +++ b/internal/openclaw/sync_preflight_test.go @@ -0,0 +1,42 @@ +package openclaw + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProbeProxyReachable(t *testing.T) { + // Server up: any HTTP status (even 401) proves serve is listening. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + addr := srv.URL + "/v1" + + reachable, warning := probeProxyReachable(context.Background(), addr, "key") + if !reachable { + t.Errorf("server up: want reachable, got unreachable (warning=%q)", warning) + } + if warning != "" { + t.Errorf("server up: want no warning, got %q", warning) + } + + // Same address after Close → connection refused → unreachable, with an + // actionable warning that names the data-plane fix. + srv.Close() + reachable, warning = probeProxyReachable(context.Background(), addr, "key") + if reachable { + t.Error("server down: want unreachable") + } + if !strings.Contains(warning, "aima serve") { + t.Errorf("server down: warning should point at `aima serve`, got %q", warning) + } + + // Empty address: no listener to check, never a false alarm. + reachable, warning = probeProxyReachable(context.Background(), "", "") + if !reachable || warning != "" { + t.Errorf("empty addr: want reachable/no-warning, got reachable=%v warning=%q", reachable, warning) + } +}