384 lines
17 KiB
PowerShell
384 lines
17 KiB
PowerShell
param(
|
|
[string]$SidecarExe = "runs/msglib-sidecar/MsgLibReadSidecar.exe",
|
|
[string]$SQLiteDllPath = $env:ISPHERE_MSGLIB_SQLITE_DLL,
|
|
[string]$DbPath = $env:ISPHERE_MSGLIB_DB,
|
|
[switch]$SkipBuild
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
Set-StrictMode -Version Latest
|
|
|
|
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
|
$sidecarPath = Join-Path $repo $SidecarExe
|
|
$harnessDir = Join-Path $repo ("runs\msglib-mcp-enrichment-harness-" + [guid]::NewGuid().ToString("N"))
|
|
$harnessMain = Join-Path $harnessDir "main.go"
|
|
|
|
function Assert-True([bool]$Condition, [string]$Message) {
|
|
if (-not $Condition) { throw $Message }
|
|
}
|
|
|
|
if (-not $SQLiteDllPath -or -not (Test-Path -LiteralPath $SQLiteDllPath)) {
|
|
throw "SQLite DLL not found; set ISPHERE_MSGLIB_SQLITE_DLL or pass -SQLiteDllPath"
|
|
}
|
|
if (-not $DbPath -or -not (Test-Path -LiteralPath $DbPath)) {
|
|
throw "MsgLib DB not found; set ISPHERE_MSGLIB_DB or pass -DbPath"
|
|
}
|
|
|
|
try {
|
|
Push-Location $repo
|
|
try {
|
|
if (-not $SkipBuild) {
|
|
$buildOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repo "scripts\build-msglib-sidecar.ps1") 2>&1
|
|
if ($LASTEXITCODE -ne 0) { throw "build-msglib-sidecar.ps1 failed with exit code $LASTEXITCODE" }
|
|
}
|
|
Assert-True (Test-Path -LiteralPath $sidecarPath) "sidecar not found: $sidecarPath"
|
|
|
|
New-Item -ItemType Directory -Force -Path $harnessDir | Out-Null
|
|
@'
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/cipher"
|
|
"crypto/des"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"isphere-ai-bridge/internal/mcpserver"
|
|
)
|
|
|
|
type summary struct {
|
|
Count int `json:"count"`
|
|
Sources []string `json:"sources"`
|
|
FirstID string
|
|
FirstDisplayName string
|
|
}
|
|
|
|
type receiveDisplaySummary struct {
|
|
MessageCount int
|
|
SenderNamePopulated bool
|
|
ConversationDisplayPopulated bool
|
|
}
|
|
|
|
type msgLibReceiveSummary struct {
|
|
MessageCount int
|
|
Sources []string
|
|
BodyValuesPresent bool
|
|
}
|
|
|
|
func main() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
|
server, err := mcpserver.NewServerFromEnv()
|
|
if err != nil { fail("server_config", err) }
|
|
serverErr := make(chan error, 1)
|
|
go func() { serverErr <- server.Run(ctx, serverTransport) }()
|
|
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "verify-msglib-mcp-enrichment", Version: "0.1.0"}, nil)
|
|
session, err := client.Connect(ctx, clientTransport, nil)
|
|
if err != nil { fail("connect", err) }
|
|
defer func() {
|
|
_ = session.Close()
|
|
cancel()
|
|
select {
|
|
case <-serverErr:
|
|
case <-time.After(2*time.Second):
|
|
fail("server_shutdown", fmt.Errorf("server did not stop"))
|
|
}
|
|
}()
|
|
|
|
contacts := callSearch(ctx, session, "isphere_search_contacts", "contacts")
|
|
groups := callSearch(ctx, session, "isphere_search_groups", "groups")
|
|
if contacts.Count == 0 { fail("contacts", fmt.Errorf("no msglib_readonly contacts returned")) }
|
|
if groups.Count == 0 { fail("groups", fmt.Errorf("no msglib_readonly groups returned")) }
|
|
if contacts.FirstID == "" || contacts.FirstDisplayName == "" { fail("contacts", fmt.Errorf("contact display fixture unavailable")) }
|
|
if groups.FirstID == "" || groups.FirstDisplayName == "" { fail("groups", fmt.Errorf("group display fixture unavailable")) }
|
|
|
|
dbReceive := verifyMsgLibReceive(ctx, session)
|
|
if dbReceive.MessageCount == 0 { fail("msglib_receive", fmt.Errorf("explicit msglib_readonly receive returned no messages")) }
|
|
|
|
receiveDisplay := verifyReceiveDisplay(ctx, contacts, groups)
|
|
if receiveDisplay.MessageCount == 0 { fail("receive_display", fmt.Errorf("receive message fixture returned no messages")) }
|
|
if !receiveDisplay.SenderNamePopulated { fail("receive_display", fmt.Errorf("sender display was not populated")) }
|
|
if !receiveDisplay.ConversationDisplayPopulated { fail("receive_display", fmt.Errorf("conversation display was not populated")) }
|
|
|
|
out := map[string]any{
|
|
"ok": true,
|
|
"verification": "msglib_mcp_enrichment",
|
|
"contact_count": contacts.Count,
|
|
"contact_sources": contacts.Sources,
|
|
"group_count": groups.Count,
|
|
"group_sources": groups.Sources,
|
|
"db_receive_smoke": true,
|
|
"db_receive_message_count": dbReceive.MessageCount,
|
|
"db_receive_sources": dbReceive.Sources,
|
|
"db_receive_body_values_present": dbReceive.BodyValuesPresent,
|
|
"receive_display_smoke": true,
|
|
"receive_message_count": receiveDisplay.MessageCount,
|
|
"receive_sender_name_populated": receiveDisplay.SenderNamePopulated,
|
|
"receive_conversation_display_populated": receiveDisplay.ConversationDisplayPopulated,
|
|
"printed_entity_values": false,
|
|
"message_body_values_printed": false,
|
|
"message_bodies_returned": false,
|
|
"file_paths_returned": false,
|
|
"raw_rows_returned": false,
|
|
}
|
|
encoded, _ := json.Marshal(out)
|
|
fmt.Println(string(encoded))
|
|
}
|
|
|
|
func callSearch(ctx context.Context, session *mcp.ClientSession, tool string, field string) summary {
|
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: map[string]any{
|
|
"query": "",
|
|
"source_preference": "auto",
|
|
"cursor": "",
|
|
"limit": 5,
|
|
}})
|
|
if err != nil { fail(tool, err) }
|
|
if result.IsError { fail(tool, fmt.Errorf("tool returned isError=true")) }
|
|
payload, _ := json.Marshal(result.StructuredContent)
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil { fail(tool+"_decode", err) }
|
|
values, ok := decoded[field].([]any)
|
|
if !ok { fail(tool+"_shape", fmt.Errorf("result field missing or not array")) }
|
|
idKey := "contact_id"
|
|
if field == "groups" { idKey = "group_id" }
|
|
sourceSet := map[string]bool{}
|
|
count := 0
|
|
out := summary{}
|
|
for _, value := range values {
|
|
item, ok := value.(map[string]any)
|
|
if !ok { continue }
|
|
source, _ := item["source"].(string)
|
|
if source != "msglib_readonly" { continue }
|
|
rawRef, _ := item["raw_ref"].(string)
|
|
if rawRef != "" { sourceSet[rawRef] = true }
|
|
id, _ := item[idKey].(string)
|
|
displayName, _ := item["display_name"].(string)
|
|
if out.FirstID == "" && strings.TrimSpace(id) != "" && strings.TrimSpace(displayName) != "" {
|
|
out.FirstID = strings.TrimSpace(id)
|
|
out.FirstDisplayName = strings.TrimSpace(displayName)
|
|
}
|
|
count++
|
|
}
|
|
sources := make([]string, 0, len(sourceSet))
|
|
for source := range sourceSet { sources = append(sources, source) }
|
|
sort.Strings(sources)
|
|
out.Count = count
|
|
out.Sources = sources
|
|
return out
|
|
}
|
|
|
|
func verifyMsgLibReceive(ctx context.Context, session *mcp.ClientSession) msgLibReceiveSummary {
|
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_receive_messages", Arguments: map[string]any{
|
|
"source_preference": "msglib_readonly",
|
|
"preview": true,
|
|
"cursor": "",
|
|
"include_attachment_metadata": false,
|
|
"limit": 5,
|
|
}})
|
|
if err != nil { fail("msglib_receive_call", err) }
|
|
if result.IsError { fail("msglib_receive_call", fmt.Errorf("tool returned isError=true")) }
|
|
payload, _ := json.Marshal(result.StructuredContent)
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil { fail("msglib_receive_decode", err) }
|
|
messages, ok := decoded["messages"].([]any)
|
|
if !ok { fail("msglib_receive_shape", fmt.Errorf("messages missing or not array")) }
|
|
sourceSet := map[string]bool{}
|
|
out := msgLibReceiveSummary{MessageCount: len(messages)}
|
|
for _, value := range messages {
|
|
message, ok := value.(map[string]any)
|
|
if !ok { continue }
|
|
rawRef, _ := message["raw_ref"].(string)
|
|
if strings.HasPrefix(rawRef, "msglib:") { sourceSet[rawRef] = true }
|
|
text, _ := message["text"].(string)
|
|
contentText, _ := message["content_text"].(string)
|
|
if strings.TrimSpace(text) != "" || strings.TrimSpace(contentText) != "" {
|
|
out.BodyValuesPresent = true
|
|
}
|
|
}
|
|
sources := make([]string, 0, len(sourceSet))
|
|
for source := range sourceSet { sources = append(sources, source) }
|
|
sort.Strings(sources)
|
|
out.Sources = sources
|
|
return out
|
|
}
|
|
|
|
func verifyReceiveDisplay(ctx context.Context, contact summary, group summary) receiveDisplaySummary {
|
|
groupPlaintext := packetFixturePlaintext("c33-group-display-fixture", group.FirstID+"/imp_pc_4.1.2.6842", contact.FirstID, "groupchat", "redacted-group-body")
|
|
directPlaintext := packetFixturePlaintext("c33-contact-display-fixture", contact.FirstID+"/imp_pc_4.1.2.6842", "receiver-redacted@imopenfire1-lanzhou", "chat", "redacted-contact-body")
|
|
groupLine, err := encryptPacketLogLineForHarness(groupPlaintext)
|
|
if err != nil { fail("receive_display_encrypt", err) }
|
|
directLine, err := encryptPacketLogLineForHarness(directPlaintext)
|
|
if err != nil { fail("receive_display_encrypt", err) }
|
|
|
|
file, err := os.CreateTemp("", "isphere-msglib-receive-display-*.txt")
|
|
if err != nil { fail("receive_display_fixture", err) }
|
|
fixturePath := file.Name()
|
|
defer os.Remove(fixturePath)
|
|
if _, err := file.WriteString("\n" + groupLine + "\n" + directLine + "\n"); err != nil {
|
|
_ = file.Close()
|
|
fail("receive_display_fixture", err)
|
|
}
|
|
if err := file.Close(); err != nil { fail("receive_display_fixture", err) }
|
|
|
|
oldFileValue, hadOldFileValue := os.LookupEnv("ISPHERE_PACKET_LOG_FILE")
|
|
oldDirValue, hadOldDirValue := os.LookupEnv("ISPHERE_PACKET_LOG_DIR")
|
|
_ = os.Unsetenv("ISPHERE_PACKET_LOG_DIR")
|
|
if err := os.Setenv("ISPHERE_PACKET_LOG_FILE", fixturePath); err != nil { fail("receive_display_env", err) }
|
|
defer func() {
|
|
if hadOldFileValue { _ = os.Setenv("ISPHERE_PACKET_LOG_FILE", oldFileValue) } else { _ = os.Unsetenv("ISPHERE_PACKET_LOG_FILE") }
|
|
if hadOldDirValue { _ = os.Setenv("ISPHERE_PACKET_LOG_DIR", oldDirValue) } else { _ = os.Unsetenv("ISPHERE_PACKET_LOG_DIR") }
|
|
}()
|
|
|
|
serverCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
|
server, err := mcpserver.NewServerFromEnv()
|
|
if err != nil { fail("receive_display_server", err) }
|
|
serverErr := make(chan error, 1)
|
|
go func() { serverErr <- server.Run(serverCtx, serverTransport) }()
|
|
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "verify-msglib-mcp-receive-display", Version: "0.1.0"}, nil)
|
|
session, err := client.Connect(serverCtx, clientTransport, nil)
|
|
if err != nil {
|
|
cancel()
|
|
fail("receive_display_connect", err)
|
|
}
|
|
defer func() {
|
|
_ = session.Close()
|
|
cancel()
|
|
select {
|
|
case <-serverErr:
|
|
case <-time.After(2*time.Second):
|
|
fail("receive_display_shutdown", fmt.Errorf("server did not stop"))
|
|
}
|
|
}()
|
|
|
|
result, err := session.CallTool(serverCtx, &mcp.CallToolParams{Name: "isphere_receive_messages", Arguments: map[string]any{
|
|
"source_preference": "auto",
|
|
"preview": true,
|
|
"cursor": "",
|
|
"include_attachment_metadata": false,
|
|
"limit": 5,
|
|
}})
|
|
if err != nil { fail("receive_display_call", err) }
|
|
if result.IsError { fail("receive_display_call", fmt.Errorf("tool returned isError=true")) }
|
|
payload, _ := json.Marshal(result.StructuredContent)
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil { fail("receive_display_decode", err) }
|
|
messages, ok := decoded["messages"].([]any)
|
|
if !ok { fail("receive_display_shape", fmt.Errorf("messages missing or not array")) }
|
|
|
|
out := receiveDisplaySummary{MessageCount: len(messages)}
|
|
if conversation, ok := decoded["conversation"].(map[string]any); ok {
|
|
displayName, _ := conversation["display_name"].(string)
|
|
out.ConversationDisplayPopulated = displayName == group.FirstDisplayName
|
|
}
|
|
for _, value := range messages {
|
|
message, ok := value.(map[string]any)
|
|
if !ok { continue }
|
|
senderID, _ := message["sender_id"].(string)
|
|
senderName, _ := message["sender_name"].(string)
|
|
if senderID == contact.FirstID && senderName == contact.FirstDisplayName {
|
|
out.SenderNamePopulated = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func packetFixturePlaintext(id string, from string, to string, messageType string, body string) string {
|
|
return "--------------------------------------------------------------------------------------------------------------------------------------------\n" +
|
|
"2026/7/10 09:30:00\n" +
|
|
fmt.Sprintf("<message id=\"%s\" from=\"%s\" to=\"%s\" type=\"%s\">\n", xmlAttr(id), xmlAttr(from), xmlAttr(to), xmlAttr(messageType)) +
|
|
fmt.Sprintf(" <body>%s</body>\n", xmlText(body)) +
|
|
" <received xmlns=\"urn:xmpp:receipts\" id=\"receipt-c33-display-fixture\" type=\"1\" stamp=\"\" />\n" +
|
|
" <subject>DISPLAY_SMOKE</subject>\n" +
|
|
" <isphere xmlns=\"isphere.im\" type=\"1001\" sendtime=\"1783423807000\" version=\"1\" />\n" +
|
|
" <readed />\n" +
|
|
"</message>"
|
|
}
|
|
|
|
func xmlAttr(value string) string {
|
|
return strings.NewReplacer("&", "&", "\"", """, "<", "<", ">", ">").Replace(value)
|
|
}
|
|
|
|
func xmlText(value string) string {
|
|
return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(value)
|
|
}
|
|
|
|
func encryptPacketLogLineForHarness(plaintext string) (string, error) {
|
|
block, err := des.NewCipher([]byte("hyhccdtm"))
|
|
if err != nil { return "", err }
|
|
plain := padPKCS7ForHarness([]byte(plaintext), des.BlockSize)
|
|
ciphertext := make([]byte, len(plain))
|
|
iv := []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
|
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plain)
|
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
func padPKCS7ForHarness(data []byte, blockSize int) []byte {
|
|
pad := blockSize - len(data)%blockSize
|
|
out := append([]byte(nil), data...)
|
|
for i := 0; i < pad; i++ { out = append(out, byte(pad)) }
|
|
return out
|
|
}
|
|
|
|
func fail(step string, err error) {
|
|
encoded, _ := json.Marshal(map[string]any{"ok": false, "step": step, "error": err.Error()})
|
|
fmt.Fprintln(os.Stderr, string(encoded))
|
|
os.Exit(1)
|
|
}
|
|
'@ | Set-Content -LiteralPath $harnessMain -Encoding UTF8
|
|
|
|
$oldSidecar = $env:ISPHERE_MSGLIB_SIDECAR_EXE
|
|
$oldDll = $env:ISPHERE_MSGLIB_SQLITE_DLL
|
|
$oldDb = $env:ISPHERE_MSGLIB_DB
|
|
$env:ISPHERE_MSGLIB_SIDECAR_EXE = (Resolve-Path -LiteralPath $sidecarPath).Path
|
|
$env:ISPHERE_MSGLIB_SQLITE_DLL = (Resolve-Path -LiteralPath $SQLiteDllPath).Path
|
|
$env:ISPHERE_MSGLIB_DB = (Resolve-Path -LiteralPath $DbPath).Path
|
|
try {
|
|
Push-Location $harnessDir
|
|
try {
|
|
$output = & go run .
|
|
if ($LASTEXITCODE -ne 0) { throw "go run MCP enrichment harness failed with exit code $LASTEXITCODE" }
|
|
$output | ForEach-Object { Write-Host $_ }
|
|
$lastLine = @($output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | Select-Object -Last 1
|
|
$json = $lastLine | ConvertFrom-Json
|
|
}
|
|
finally { Pop-Location }
|
|
}
|
|
finally {
|
|
$env:ISPHERE_MSGLIB_SIDECAR_EXE = $oldSidecar
|
|
$env:ISPHERE_MSGLIB_SQLITE_DLL = $oldDll
|
|
$env:ISPHERE_MSGLIB_DB = $oldDb
|
|
}
|
|
|
|
Assert-True ($json.ok -eq $true) "harness did not return ok=true"
|
|
Assert-True ($json.printed_entity_values -eq $false) "harness must not print entity values"
|
|
Assert-True ($json.contact_count -gt 0) "contact_count should be > 0"
|
|
Assert-True ($json.group_count -gt 0) "group_count should be > 0"
|
|
Assert-True (($json.PSObject.Properties.Name -contains "db_receive_smoke") -and $json.db_receive_smoke -eq $true) "db receive smoke should be true"
|
|
Assert-True (($json.PSObject.Properties.Name -contains "db_receive_message_count") -and $json.db_receive_message_count -gt 0) "db_receive_message_count should be > 0"
|
|
Assert-True (($json.PSObject.Properties.Name -contains "message_body_values_printed") -and $json.message_body_values_printed -eq $false) "message body values must not be printed"
|
|
Assert-True (($json.PSObject.Properties.Name -contains "receive_display_smoke") -and $json.receive_display_smoke -eq $true) "receive display smoke should be true"
|
|
Assert-True (($json.PSObject.Properties.Name -contains "receive_sender_name_populated") -and $json.receive_sender_name_populated -eq $true) "receive sender_name should be populated"
|
|
Assert-True (($json.PSObject.Properties.Name -contains "receive_conversation_display_populated") -and $json.receive_conversation_display_populated -eq $true) "receive conversation.display_name should be populated"
|
|
}
|
|
finally { Pop-Location }
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $harnessDir) {
|
|
Remove-Item -LiteralPath $harnessDir -Recurse -Force
|
|
}
|
|
}
|