Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions internal/openclaw/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions internal/openclaw/sync_preflight_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading