mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-28 08:20:15 +00:00
* fix(vault): prevent vault_read id-namespace collision vault_search was leaking KG/episodic entity ids into result sets even when narrow `types` were requested, and callers then passed those ids to vault_read which returned a generic "document not found". The cause was threefold: 1. `types` filter was only applied to the vault fan-out; KG and episodic ran unconditionally. Now gated by shouldFanout(types, key). 2. vault_search output lacked a per-source tool hint. Each result now ends with " → use <tool>" naming the correct follow-up (vault_read, knowledge_graph_search, or memory_search). 3. vault_read miss returned "document not found" without checking whether the id belonged to a foreign namespace. It now probes KG then episodic and returns a namespace-specific redirect error. Stores are injected via SetKGStore/SetEpisodicStore, nil-safe, tenant-scoped. Adds red→green characterization tests plus an end-to-end integration scenario seeding a vault doc + KG entity with identical basenames. * test(agent): bump none-mode prompt size budget to 3100 vault_read wiring (#948) added ~95 chars to read_file tool summary, pushing none-mode prompt from <3000 to 3075 chars. Bump budget to 3100 (~775 tokens) to match the intentional addition. * test(integration): ensure data_migrations table exists in reset helper The reset helper runs before RunPendingHooks, but RunPendingHooks is what normally creates data_migrations. On a fresh CI database the DELETE fails with 'relation does not exist'. Create the table defensively so reset works regardless of execution order. * refactor(vault): per-source id fields + wire episodic into search Align vault_search output fields with downstream tool input params: doc_id (vault_read), entity_id (knowledge_graph_search), episodic_id (memory_expand). Prevents LLMs from pattern-matching a generic `id:` and misrouting a foreign-namespace uuid into vault_read. Fallback redirect in vault_read now quotes id + names the correct param so the LLM can self-correct in one turn. Also wire stores.Episodic into VaultSearchService (stale comment claimed pending-impl; PGEpisodicStore has existed and been in use since v3). Unifies search fan-out with vault_read namespace probe. --------- Co-authored-by: viettranx <viettranx@gmail.com>
334 lines
10 KiB
Go
334 lines
10 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
|
|
)
|
|
|
|
// TestWebSearchMigrateHook_ConfigJSON5Path tests migration of inline keys
|
|
// from config.json5 to config_secrets (sub-test A).
|
|
func TestWebSearchMigrateHook_ConfigJSON5Path(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Create a temp config.json5 with inline Brave key
|
|
tmpDir := t.TempDir()
|
|
configPath := tmpDir + "/config.json5"
|
|
configContent := []byte(`{
|
|
"tools": {
|
|
"web": {
|
|
"brave": {
|
|
"api_key": "brave-key-from-config-json5"
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
if err := os.WriteFile(configPath, configContent, 0600); err != nil {
|
|
t.Fatalf("write config file: %v", err)
|
|
}
|
|
|
|
// Set environment variables for the hook
|
|
oldConfigPath := os.Getenv("GOCLAW_CONFIG")
|
|
oldEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
|
|
defer func() {
|
|
os.Setenv("GOCLAW_CONFIG", oldConfigPath)
|
|
os.Setenv("GOCLAW_ENCRYPTION_KEY", oldEncKey)
|
|
}()
|
|
|
|
os.Setenv("GOCLAW_CONFIG", configPath)
|
|
os.Setenv("GOCLAW_ENCRYPTION_KEY", testEncryptionKey)
|
|
|
|
resetWebSearchMigrateHook(t, db)
|
|
|
|
// Run the migration hook
|
|
if _, err := upgrade.RunPendingHooks(ctx, db); err != nil {
|
|
t.Fatalf("RunPendingHooks: %v", err)
|
|
}
|
|
|
|
// Verify row was created in config_secrets
|
|
var count int
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM config_secrets WHERE key=$1 AND tenant_id=$2`,
|
|
"tools.web.brave.api_key", store.MasterTenantID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("count query: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 secret row, got %d", count)
|
|
}
|
|
|
|
// Verify the encrypted value can be decrypted
|
|
var encValue []byte
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT value FROM config_secrets WHERE key=$1 AND tenant_id=$2`,
|
|
"tools.web.brave.api_key", store.MasterTenantID).Scan(&encValue)
|
|
if err != nil {
|
|
t.Fatalf("read secret: %v", err)
|
|
}
|
|
|
|
decrypted, err := crypto.Decrypt(string(encValue), testEncryptionKey)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
if decrypted != "brave-key-from-config-json5" {
|
|
t.Errorf("decrypted value mismatch: got %q", decrypted)
|
|
}
|
|
|
|
// Re-run the hook (should be idempotent)
|
|
if _, err := upgrade.RunPendingHooks(ctx, db); err != nil && err.Error() != "no pending hooks" {
|
|
// It's OK if there are no pending hooks on re-run
|
|
}
|
|
|
|
// Verify still only 1 row (no duplicate created)
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM config_secrets WHERE key=$1 AND tenant_id=$2`,
|
|
"tools.web.brave.api_key", store.MasterTenantID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("count query after re-run: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected still 1 secret row after re-run, got %d", count)
|
|
}
|
|
|
|
// Cleanup
|
|
db.ExecContext(ctx,
|
|
`DELETE FROM config_secrets WHERE key=$1 AND tenant_id=$2`,
|
|
"tools.web.brave.api_key", store.MasterTenantID)
|
|
}
|
|
|
|
// TestWebSearchMigrateHook_SettingsBlobLegacy tests migration of inline keys
|
|
// from builtin_tool_tenant_configs.settings JSON blob (sub-test B).
|
|
func TestWebSearchMigrateHook_SettingsBlobLegacy(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
|
|
// Pre-seed a builtin_tool_tenant_configs row with inline Exa key in settings
|
|
settingsBlob := map[string]interface{}{
|
|
"exa": map[string]interface{}{
|
|
"api_key": "legacy-exa-key-from-settings",
|
|
"enabled": true,
|
|
},
|
|
"provider_order": []string{"exa", "duckduckgo"},
|
|
}
|
|
settingsJSON, err := json.Marshal(settingsBlob)
|
|
if err != nil {
|
|
t.Fatalf("marshal settings: %v", err)
|
|
}
|
|
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO builtin_tool_tenant_configs (tenant_id, tool_name, settings, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (tenant_id, tool_name) DO NOTHING`,
|
|
tenantID, "web_search", settingsJSON)
|
|
if err != nil {
|
|
t.Fatalf("insert tool config: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
db.ExecContext(ctx,
|
|
`DELETE FROM builtin_tool_tenant_configs WHERE tenant_id=$1 AND tool_name=$2`,
|
|
tenantID, "web_search")
|
|
db.ExecContext(ctx,
|
|
`DELETE FROM config_secrets WHERE tenant_id=$1 AND key=$2`,
|
|
tenantID, "tools.web.exa.api_key")
|
|
})
|
|
|
|
// Set encryption key for the hook
|
|
oldEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
|
|
defer os.Setenv("GOCLAW_ENCRYPTION_KEY", oldEncKey)
|
|
os.Setenv("GOCLAW_ENCRYPTION_KEY", testEncryptionKey)
|
|
|
|
resetWebSearchMigrateHook(t, db)
|
|
|
|
// Run the migration hook
|
|
if _, err := upgrade.RunPendingHooks(context.Background(), db); err != nil {
|
|
t.Fatalf("RunPendingHooks: %v", err)
|
|
}
|
|
|
|
// Verify secret was created
|
|
var encValue []byte
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT value FROM config_secrets WHERE key=$1 AND tenant_id=$2`,
|
|
"tools.web.exa.api_key", tenantID).Scan(&encValue)
|
|
if err == sql.ErrNoRows {
|
|
t.Fatalf("secret not created")
|
|
} else if err != nil {
|
|
t.Fatalf("read secret: %v", err)
|
|
}
|
|
|
|
// Verify decrypted value matches
|
|
decrypted, err := crypto.Decrypt(string(encValue), testEncryptionKey)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
if decrypted != "legacy-exa-key-from-settings" {
|
|
t.Errorf("decrypted value mismatch: got %q", decrypted)
|
|
}
|
|
|
|
// Verify the api_key field was removed from settings blob
|
|
var settingsAfter []byte
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT settings FROM builtin_tool_tenant_configs WHERE tenant_id=$1 AND tool_name=$2`,
|
|
tenantID, "web_search").Scan(&settingsAfter)
|
|
if err != nil {
|
|
t.Fatalf("read settings: %v", err)
|
|
}
|
|
|
|
var settingsMap map[string]interface{}
|
|
if err := json.Unmarshal(settingsAfter, &settingsMap); err != nil {
|
|
t.Fatalf("unmarshal settings: %v", err)
|
|
}
|
|
|
|
// Check that exa section no longer has api_key
|
|
exaSection, ok := settingsMap["exa"].(map[string]interface{})
|
|
if ok && exaSection["api_key"] != nil {
|
|
t.Errorf("api_key should have been stripped from settings, but found: %v", exaSection["api_key"])
|
|
}
|
|
|
|
// Verify other fields in exa section are preserved
|
|
if exaSection != nil && exaSection["enabled"] != true {
|
|
t.Errorf("enabled field should be preserved in settings")
|
|
}
|
|
|
|
// Verify provider_order is still present
|
|
if _, ok := settingsMap["provider_order"]; !ok {
|
|
t.Errorf("provider_order should be preserved in settings")
|
|
}
|
|
}
|
|
|
|
// TestWebSearchMigrateHook_ExistingSecretNoOverwrite tests that existing
|
|
// secrets are not overwritten by the migration (sub-test C).
|
|
func TestWebSearchMigrateHook_ExistingSecretNoOverwrite(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
|
|
// Pre-seed an existing secret with one value
|
|
existingKey := "existing-brave-key-from-ui"
|
|
encExisting, err := crypto.Encrypt(existingKey, testEncryptionKey)
|
|
if err != nil {
|
|
t.Fatalf("encrypt existing: %v", err)
|
|
}
|
|
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO config_secrets (key, value, updated_at, tenant_id)
|
|
VALUES ($1, $2, NOW(), $3)`,
|
|
"tools.web.brave.api_key", []byte(encExisting), tenantID)
|
|
if err != nil {
|
|
t.Fatalf("insert existing secret: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
db.ExecContext(ctx,
|
|
`DELETE FROM config_secrets WHERE tenant_id=$1 AND key=$2`,
|
|
tenantID, "tools.web.brave.api_key")
|
|
db.ExecContext(ctx,
|
|
`DELETE FROM builtin_tool_tenant_configs WHERE tenant_id=$1 AND tool_name=$2`,
|
|
tenantID, "web_search")
|
|
})
|
|
|
|
// Pre-seed settings blob with a DIFFERENT Brave key
|
|
settingsBlob := map[string]interface{}{
|
|
"brave": map[string]interface{}{
|
|
"api_key": "different-brave-key-from-settings",
|
|
"enabled": true,
|
|
},
|
|
}
|
|
settingsJSON, err := json.Marshal(settingsBlob)
|
|
if err != nil {
|
|
t.Fatalf("marshal settings: %v", err)
|
|
}
|
|
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO builtin_tool_tenant_configs (tenant_id, tool_name, settings, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (tenant_id, tool_name) DO NOTHING`,
|
|
tenantID, "web_search", settingsJSON)
|
|
if err != nil {
|
|
t.Fatalf("insert tool config: %v", err)
|
|
}
|
|
|
|
// Set encryption key for the hook
|
|
oldEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
|
|
defer os.Setenv("GOCLAW_ENCRYPTION_KEY", oldEncKey)
|
|
os.Setenv("GOCLAW_ENCRYPTION_KEY", testEncryptionKey)
|
|
|
|
resetWebSearchMigrateHook(t, db)
|
|
|
|
// Run the migration hook
|
|
if _, err := upgrade.RunPendingHooks(context.Background(), db); err != nil {
|
|
t.Fatalf("RunPendingHooks: %v", err)
|
|
}
|
|
|
|
// Verify the existing secret was NOT overwritten
|
|
var encValue []byte
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT value FROM config_secrets WHERE key=$1 AND tenant_id=$2`,
|
|
"tools.web.brave.api_key", tenantID).Scan(&encValue)
|
|
if err != nil {
|
|
t.Fatalf("read secret: %v", err)
|
|
}
|
|
|
|
decrypted, err := crypto.Decrypt(string(encValue), testEncryptionKey)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
|
|
// Should still have the EXISTING key value, not the settings-blob value
|
|
if decrypted != existingKey {
|
|
t.Errorf("existing secret was overwritten. Expected %q, got %q", existingKey, decrypted)
|
|
}
|
|
|
|
// Verify the api_key field was still removed from settings (cleanup)
|
|
var settingsAfter []byte
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT settings FROM builtin_tool_tenant_configs WHERE tenant_id=$1 AND tool_name=$2`,
|
|
tenantID, "web_search").Scan(&settingsAfter)
|
|
if err != nil {
|
|
t.Fatalf("read settings: %v", err)
|
|
}
|
|
|
|
var settingsMap map[string]interface{}
|
|
if err := json.Unmarshal(settingsAfter, &settingsMap); err != nil {
|
|
t.Fatalf("unmarshal settings: %v", err)
|
|
}
|
|
|
|
braveSection, ok := settingsMap["brave"].(map[string]interface{})
|
|
if ok && braveSection["api_key"] != nil {
|
|
t.Errorf("api_key should have been stripped from settings even when existing secret exists")
|
|
}
|
|
}
|
|
|
|
// resetWebSearchMigrateHook deletes the data_migrations row for the web_search
|
|
// migrate hook so RunPendingHooks re-executes it. testDB is shared across tests,
|
|
// so without this the hook runs only once per test-binary invocation.
|
|
func resetWebSearchMigrateHook(t *testing.T, db *sql.DB) {
|
|
t.Helper()
|
|
// Ensure table exists — first test run creates it via RunPendingHooks,
|
|
// but reset must work on a fresh DB too.
|
|
if _, err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS data_migrations (
|
|
name VARCHAR(255) PRIMARY KEY,
|
|
version INT NOT NULL,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`); err != nil {
|
|
t.Fatalf("ensure data_migrations: %v", err)
|
|
}
|
|
if _, err := db.Exec(
|
|
`DELETE FROM data_migrations WHERE name=$1`,
|
|
"055_web_search_legacy_keys_to_config_secrets"); err != nil {
|
|
t.Fatalf("reset hook: %v", err)
|
|
}
|
|
}
|