Files
goclaw/cmd/gateway_agents_test.go
Cesc 254081462c fix(memory): apply Docker localhost rewrite in embedding provider constructor
Model listing and chat provider construction rewrite localhost to
host.docker.internal via config.DockerLocalhost() when running inside
Docker, but all three memory.NewOpenAIEmbeddingProvider call sites
(verify-embedding handler, runtime memory embedding) passed the raw
api_base through. In Docker the container resolved localhost to itself,
so Ollama Verify Embedding and runtime embedding failed with connection
refused while the model dropdown loaded fine.

Centralize the rewrite inside the constructor so no call site can miss
it, add a SetInDockerForTest hook for environment-independent regression
tests, and pin Docker detection off in httptest-based suites.

Fixes #1519
2026-08-21 10:57:36 +07:00

212 lines
7.6 KiB
Go

package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// --- Red Team F3: subagent ExecTool must inherit secureCLIStore wiring ---
type stubSecureCLIStoreCmd struct{}
func (s *stubSecureCLIStoreCmd) Create(ctx context.Context, b *store.SecureCLIBinary) error {
return nil
}
func (s *stubSecureCLIStoreCmd) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStoreCmd) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
return nil
}
func (s *stubSecureCLIStoreCmd) Delete(ctx context.Context, id uuid.UUID) error { return nil }
func (s *stubSecureCLIStoreCmd) List(ctx context.Context) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStoreCmd) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStoreCmd) ListForAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStoreCmd) IsRegisteredBinary(ctx context.Context, binaryName string) (bool, error) {
return false, nil
}
func (s *stubSecureCLIStoreCmd) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*store.SecureCLIBinary, error) {
return nil, nil
}
func (s *stubSecureCLIStoreCmd) GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*store.SecureCLIUserCredential, error) {
return nil, nil
}
func (s *stubSecureCLIStoreCmd) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error {
return nil
}
func (s *stubSecureCLIStoreCmd) SetUserCredentialsTyped(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte, credentialType, hostScope *string) error {
return nil
}
func (s *stubSecureCLIStoreCmd) DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error {
return nil
}
func (s *stubSecureCLIStoreCmd) ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIUserCredential, error) {
return nil, nil
}
// TestSubagentExecTool_StoreWired ensures the subagent tool factory wires the
// SecureCLIStore into the subagent's ExecTool, so the gate enforces on
// spawned-subagent exec (Red Team F3). A missing wiring would let a parent
// agent bypass the gate by delegating the exec to a subagent.
func TestSubagentExecTool_StoreWired(t *testing.T) {
parent := tools.NewRegistry()
stub := &stubSecureCLIStoreCmd{}
_, execTool := buildSubagentToolsRegistry(parent, t.TempDir(), false, nil, stub)
if execTool == nil {
t.Fatal("expected non-nil exec tool from factory")
}
if !execTool.HasSecureCLIStore() {
t.Fatalf("expected subagent ExecTool to have SecureCLIStore wired (Red Team F3)")
}
}
// TestSubagentExecTool_NilStoreIsSafe ensures the factory does not panic when
// the store is unavailable (Lite edition / no encryption key). The exec tool
// simply lacks the gate — same as today's Lite behavior.
func TestSubagentExecTool_NilStoreIsSafe(t *testing.T) {
parent := tools.NewRegistry()
_, execTool := buildSubagentToolsRegistry(parent, t.TempDir(), false, nil, nil)
if execTool == nil {
t.Fatal("expected non-nil exec tool from factory")
}
if execTool.HasSecureCLIStore() {
t.Fatalf("expected no SecureCLIStore when passed nil (Lite path)")
}
}
func captureEmbeddingRequest(t *testing.T, es *store.EmbeddingSettings) map[string]any {
t.Helper()
// Pin Docker detection off so the loopback httptest URL is not rewritten
// to host.docker.internal when this suite runs inside a container.
t.Cleanup(config.SetInDockerForTest(false))
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatalf("Decode() error = %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{
"embedding": make([]float32, store.RequiredMemoryEmbeddingDimensions),
"index": 0,
}},
})
}))
defer server.Close()
provider := &store.LLMProviderData{
Name: "embedding-provider",
ProviderType: store.ProviderOpenAICompat,
APIKey: "test-key",
APIBase: server.URL,
Enabled: true,
}
ep := buildEmbeddingProvider(provider, es, nil, nil)
if ep == nil {
t.Fatal("buildEmbeddingProvider() = nil, want provider")
}
if _, err := ep.Embed(context.Background(), []string{"hello"}); err != nil {
t.Fatalf("Embed() error = %v", err)
}
return requestBody
}
func TestBuildEmbeddingProviderDefaultsTo1536Dimensions(t *testing.T) {
requestBody := captureEmbeddingRequest(t, nil)
if got := requestBody["dimensions"]; got != float64(1536) {
t.Fatalf("dimensions = %v, want 1536", got)
}
}
func TestBuildEmbeddingProviderIgnoresIncompatibleStoredDimensions(t *testing.T) {
requestBody := captureEmbeddingRequest(t, &store.EmbeddingSettings{
Enabled: true,
Model: "voyage-4-nano",
Dimensions: 2048,
})
if got := requestBody["dimensions"]; got != float64(1536) {
t.Fatalf("dimensions = %v, want fallback 1536", got)
}
}
type embeddingProviderStoreStub struct {
masterProviders []store.LLMProviderData
allProviders []store.LLMProviderData
listTenant uuid.UUID
listAllCalled bool
}
func (s *embeddingProviderStoreStub) CreateProvider(context.Context, *store.LLMProviderData) error {
return nil
}
func (s *embeddingProviderStoreStub) GetProvider(context.Context, uuid.UUID) (*store.LLMProviderData, error) {
return nil, nil
}
func (s *embeddingProviderStoreStub) GetProviderByName(context.Context, string) (*store.LLMProviderData, error) {
return nil, nil
}
func (s *embeddingProviderStoreStub) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) {
s.listTenant = store.TenantIDFromContext(ctx)
return s.masterProviders, nil
}
func (s *embeddingProviderStoreStub) ListAllProviders(context.Context) ([]store.LLMProviderData, error) {
s.listAllCalled = true
return s.allProviders, nil
}
func (s *embeddingProviderStoreStub) UpdateProvider(context.Context, uuid.UUID, map[string]any) error {
return nil
}
func (s *embeddingProviderStoreStub) DeleteProvider(context.Context, uuid.UUID) error { return nil }
func TestResolveEmbeddingProviderAutoDetectUsesMasterTenantOnly(t *testing.T) {
embeddingSettings := json.RawMessage(`{"embedding":{"enabled":true}}`)
providerStore := &embeddingProviderStoreStub{
masterProviders: []store.LLMProviderData{{
TenantID: store.MasterTenantID,
Name: "master-embedding",
ProviderType: store.ProviderOpenAICompat,
APIBase: "http://master.invalid/v1",
APIKey: "master-key",
Enabled: true,
Settings: embeddingSettings,
}},
allProviders: []store.LLMProviderData{{
TenantID: uuid.New(),
Name: "tenant-controlled-endpoint",
ProviderType: store.ProviderOpenAICompat,
Enabled: true,
Settings: embeddingSettings,
}},
}
provider := resolveEmbeddingProvider(providerStore, nil, nil)
if provider == nil || provider.Name() != "master-embedding" {
t.Fatalf("resolveEmbeddingProvider() = %v, want master-embedding", provider)
}
if providerStore.listTenant != store.MasterTenantID {
t.Fatalf("ListProviders tenant = %s, want master tenant", providerStore.listTenant)
}
if providerStore.listAllCalled {
t.Fatal("ListAllProviders was called; cross-tenant auto-detect must stay disabled")
}
}