From 878b729015da26fc84f5593d1b47a5db5a39264c Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Thu, 9 Jul 2026 23:27:52 +0800 Subject: [PATCH] test: verify configured receive source smoke --- docs/current-status-card.md | 11 +- docs/go-mcp-runbook.md | 3 +- ...tage-c-log-backed-receive-messages-loop.md | 69 +++++++++- scripts/verify-go-mcp.ps1 | 129 ++++++++++++++++++ 4 files changed, 202 insertions(+), 10 deletions(-) diff --git a/docs/current-status-card.md b/docs/current-status-card.md index e5fef32..d32c52b 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -21,7 +21,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor - `win_helper_dump_uia` - `isphere_receive_messages` - C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract. -- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, five-tool MCP surface, and default empty-source `isphere_receive_messages` smoke call. +- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, five-tool MCP surface, default empty-source `isphere_receive_messages`, and synthetic configured-source receive smoke. - N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures. - N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only. @@ -48,7 +48,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `Stage C / Loop C6 - fixture-backed operator smoke for configured receive source`. +Current loop: `Stage C / Loop C7 - log-backed contact candidate extraction`. Plan file: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`. @@ -67,11 +67,12 @@ Current loop output so far: 4. C4: repeatable operator verification and docs for the five-tool surface. 5. C5: safe `ISPHERE_PACKET_LOG_FILE` loader for operator-local encrypted PacketReader log lines. 6. C6: fixture-backed operator smoke for configured receive source. +7. C7: log-backed contact candidate extraction from sender/receiver JIDs. ## Next business mainline -1. Finish Stage C Loop C6: add fixture-backed configured-source operator smoke. -2. Rewrite the next loop around either production-safe source configuration hardening or the next read capability, based on C6 results. -3. Add contact/group/file read tools after their source decisions are recorded. +1. Implement Stage C Loop C7: extract searchable contact candidates from log-backed messages. +2. Register `isphere_search_contacts` after contact source tests pass. +3. Add group/file read tools after their source decisions are recorded. 4. Add `isphere_send_message` and `isphere_send_file` after source discovery and connector selection. 5. Keep UIA selector/report work as fallback support, not as the primary roadmap. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 12ce843..42e3f87 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -93,6 +93,7 @@ The verification confirms: - Exactly five tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`. - `win_helper_version` returns `ISphereWinHelper`. - `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source. +- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message. - Verification uses synthetic/local helper checks and does not load raw N12-pre evidence. - Contact/group/file/search/send business tools remain later-stage work. @@ -112,7 +113,7 @@ $env:ISPHERE_PACKET_LOG_FILE = "E:\coding\codex\isphere-ai-bridge\runs\offline-e The file must contain one encrypted Base64 log line per line. Blank lines are ignored. Keep this file under ignored `runs/` paths or another operator-local location; do not commit raw logs or decrypted message content. -Then start the MCP server normally. The command entry point uses `ISPHERE_PACKET_LOG_FILE` if it is set; the repeatable verification script clears it so the default smoke test remains deterministic. +Then start the MCP server normally. The command entry point uses `ISPHERE_PACKET_LOG_FILE` if it is set. The repeatable verification script checks both paths: it clears the variable for the default empty-source smoke test, then creates a temporary synthetic encrypted fixture file and proves the configured-source path returns one redacted message. ## 7. Configure MCP client command diff --git a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md index 57ce198..ea4b6fb 100644 --- a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md +++ b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md @@ -864,7 +864,7 @@ Implemented and verified in this loop: - New fixture smoke creates a temporary synthetic/redacted PacketReader plaintext, encrypts it with the known log policy inside the temporary Go harness, writes one encrypted line to a temp file, sets `ISPHERE_PACKET_LOG_FILE` to that temp file, starts `mcpserver.NewServerFromEnv()`, calls `isphere_receive_messages`, and asserts one normalized message. - No raw N12-pre evidence or decrypted user content is written to the repo. -- [ ] **Step 1: Capture current fixture-smoke gap** +- [x] **Step 1: Capture current fixture-smoke gap** Run the current verification script and note that it only proves the default empty source, not configured fixture ingestion: @@ -874,15 +874,15 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1 Expected: PASS with `receive_message_count: 0`, but no configured-source proof yet. -- [ ] **Step 2: Update the temporary Go harness** +- [x] **Step 2: Update the temporary Go harness** Extend the generated harness with a second server session that sets `ISPHERE_PACKET_LOG_FILE` to a temp synthetic encrypted log-line file and asserts one returned message with redacted content. -- [ ] **Step 3: Update docs** +- [x] **Step 3: Update docs** Update the runbook/status card to say verification covers both default empty source and synthetic configured-source smoke. -- [ ] **Step 4: Run full verification and commit** +- [x] **Step 4: Run full verification and commit** Run: @@ -897,6 +897,67 @@ Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. C --- +## Loop C6 Result + +Implemented and verified in this loop: + +- `scripts/verify-go-mcp.ps1` now verifies two receive paths: default empty source (`receive_message_count: 0`) and synthetic configured source (`configured_receive_message_count: 1`). +- The configured-source smoke creates a temporary encrypted PacketReader fixture line inside the generated Go harness, sets `ISPHERE_PACKET_LOG_FILE`, starts `mcpserver.NewServerFromEnv()`, calls `isphere_receive_messages`, and asserts one normalized redacted message. +- Docs now state that operator verification covers both default empty-source and synthetic configured-source paths. +- Verification completed for C6 with `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1`; full verification is run again before commit. + +--- + +## Loop C7: Log-backed contact candidate extraction + +**Goal:** Build the next read-capability foundation by extracting searchable contact candidates from normalized log-backed messages, using sender/receiver bare JIDs as stable contact ids until richer roster/display-name evidence is available. + +**Contract source:** `docs/mcp-core-tools-contract.md`, tool `isphere_search_contacts`. + +**Planned files:** +- Create: `internal/isphere/contacts.go` +- Create: `internal/isphere/contacts_test.go` +- Modify: `docs/source-discovery/capability-source-matrix.md` +- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md` + +**Planned behavior:** +- Consume `[]isphere.Message` records from the existing receive source layer. +- Extract unique contacts from `SenderID` and `ReceiverID`. +- Contact fields: `ContactID`, `DisplayName`, `Account`, `Source`, `Confidence`, `RawRef`. +- For now, `DisplayName` and `Account` both use the bare JID because no richer roster evidence is validated yet. +- Search query matches bare JID/display name case-insensitively. +- `Limit <= 0` returns all matches; positive `Limit` truncates. +- This loop does not register the MCP tool yet; registration comes after source behavior passes tests. + +- [ ] **Step 1: Write failing contact extraction test** + +Create `internal/isphere/contacts_test.go` and run: + +```powershell +go test ./internal/isphere -run TestSearchContactsFromMessagesMatchesBareJIDs -v +``` + +Expected initial failure: contact search types/functions do not exist. + +- [ ] **Step 2: Implement contact extraction/search** + +Create `internal/isphere/contacts.go` with minimal pure Go logic over normalized messages. Use synthetic/redacted fixtures only. + +- [ ] **Step 3: Run full verification and rewrite Loop C8** + +Run: + +```powershell +git diff --check +go test ./... +go build ./cmd/isphere-mcp +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1 +``` + +Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. Rewrite Loop C8 around registering `isphere_search_contacts` from the C7 source behavior. + +--- + ## Self-Review - Spec coverage: the plan follows the user's cyclic requirement: plan first, implement one loop at a time, update the next loop after each result, and ask only on blockers. diff --git a/scripts/verify-go-mcp.ps1 b/scripts/verify-go-mcp.ps1 index 0f92cb0..7ab6eb1 100644 --- a/scripts/verify-go-mcp.ps1 +++ b/scripts/verify-go-mcp.ps1 @@ -57,6 +57,9 @@ package main import ( "context" + "crypto/cipher" + "crypto/des" + "encoding/base64" "encoding/json" "fmt" "os" @@ -181,6 +184,7 @@ func main() { if len(messagesValue) != 0 { fail("tools/call isphere_receive_messages", fmt.Errorf("default empty source returned %d messages, want 0", len(messagesValue))) } + configuredReceiveCount := verifyConfiguredReceiveSource(ctx) result := map[string]any{ "ok": true, @@ -190,6 +194,7 @@ func main() { "tools": toolNames, "helper_name": helperName, "receive_message_count": len(messagesValue), + "configured_receive_message_count": configuredReceiveCount, "packet_log_file_configured": os.Getenv("ISPHERE_PACKET_LOG_FILE") != "", "real_isphere_login_required": false, "python_runtime_required": false, @@ -199,6 +204,128 @@ func main() { fmt.Println(string(encoded)) } + +func verifyConfiguredReceiveSource(ctx context.Context) int { + plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\n" + + "2026/7/7 15:30:07\n" + + "\n" + + " redacted fixture\n" + + " \n" + + " VERIFY_FIXTURE\n" + + " \n" + + " \n" + + "" + line, err := encryptPacketLogLineForHarness(plaintext) + if err != nil { + fail("fixture/encrypt", err) + } + file, err := os.CreateTemp("", "isphere-packet-log-*.txt") + if err != nil { + fail("fixture/create", err) + } + fixturePath := file.Name() + defer os.Remove(fixturePath) + if _, err := file.WriteString("\n" + line + "\n"); err != nil { + _ = file.Close() + fail("fixture/write", err) + } + if err := file.Close(); err != nil { + fail("fixture/close", err) + } + + oldValue, hadOldValue := os.LookupEnv("ISPHERE_PACKET_LOG_FILE") + if err := os.Setenv("ISPHERE_PACKET_LOG_FILE", fixturePath); err != nil { + fail("fixture/env", err) + } + defer func() { + if hadOldValue { + _ = os.Setenv("ISPHERE_PACKET_LOG_FILE", oldValue) + } else { + _ = os.Unsetenv("ISPHERE_PACKET_LOG_FILE") + } + }() + + serverCtx, cancel := context.WithCancel(ctx) + defer cancel() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + server, err := mcpserver.NewServerFromEnv() + if err != nil { + fail("fixture/server_config", err) + } + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run(serverCtx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "verify-go-mcp-fixture", Version: "0.1.0"}, nil) + session, err := client.Connect(serverCtx, clientTransport, nil) + if err != nil { + cancel() + fail("fixture/connect", err) + } + defer func() { + _ = session.Close() + cancel() + select { + case <-serverErr: + case <-time.After(2 * time.Second): + fail("fixture/server_shutdown", fmt.Errorf("server did not stop")) + } + }() + + callResult, err := session.CallTool(serverCtx, &mcp.CallToolParams{Name: "isphere_receive_messages", Arguments: map[string]any{"limit": 5}}) + if err != nil { + fail("fixture/call isphere_receive_messages", err) + } + if callResult.IsError { + fail("fixture/call isphere_receive_messages", fmt.Errorf("tool returned isError=true: %#v", callResult.Content)) + } + var receivePayload map[string]any + receiveEncoded, _ := json.Marshal(callResult.StructuredContent) + if err := json.Unmarshal(receiveEncoded, &receivePayload); err != nil { + fail("fixture/decode", fmt.Errorf("decode structuredContent %s: %w", receiveEncoded, err)) + } + messagesValue, ok := receivePayload["messages"].([]any) + if !ok { + fail("fixture/decode", fmt.Errorf("messages missing or not array: %s", receiveEncoded)) + } + if len(messagesValue) != 1 { + fail("fixture/assert", fmt.Errorf("configured source returned %d messages, want 1", len(messagesValue))) + } + message, ok := messagesValue[0].(map[string]any) + if !ok { + fail("fixture/assert", fmt.Errorf("message was not object: %#v", messagesValue[0])) + } + if message["id"] != "msg-fixture-1" || message["text"] != "redacted fixture" || message["subject"] != "VERIFY_FIXTURE" { + fail("fixture/assert", fmt.Errorf("unexpected message: %#v", message)) + } + if message["conversation_id"] != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" { + fail("fixture/assert", fmt.Errorf("conversation_id = %#v", message["conversation_id"])) + } + return len(messagesValue) +} + +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) { payload, _ := json.Marshal(map[string]any{"ok": false, "step": step, "error": err.Error()}) fmt.Fprintln(os.Stderr, string(payload)) @@ -228,6 +355,7 @@ func fail(step string, err error) { Assert-True ($script:harnessJson.helper_name -eq "ISphereWinHelper") "SDK harness helper_name mismatch: $($script:harnessJson.helper_name)" Assert-True ($script:harnessJson.tool_count -eq 5) "SDK harness tool_count mismatch: $($script:harnessJson.tool_count)" Assert-True ($script:harnessJson.receive_message_count -eq 0) "SDK harness receive_message_count mismatch: $($script:harnessJson.receive_message_count)" + Assert-True ($script:harnessJson.configured_receive_message_count -eq 1) "SDK harness configured_receive_message_count mismatch: $($script:harnessJson.configured_receive_message_count)" [ordered]@{ ok = $true @@ -237,6 +365,7 @@ func fail(step string, err error) { tool_count = $script:harnessJson.tool_count tools = $script:harnessJson.tools receive_message_count = $script:harnessJson.receive_message_count + configured_receive_message_count = $script:harnessJson.configured_receive_message_count packet_log_file_configured = $script:harnessJson.packet_log_file_configured python_runtime_required = $false real_isphere_login_required = $false