105 Commits

Author SHA1 Message Date
zhaoyilun
a4242c12be docs: add offline b-route reverse decision 2026-07-12 02:36:59 +08:00
zhaoyilun
485d5ea766 docs: plan offline b-route reverse work 2026-07-12 01:44:01 +08:00
zhaoyilun
eb399853e6 docs: plan b-route bridge next steps 2026-07-12 01:36:28 +08:00
zhaoyilun
0bb07c7754 docs: clarify rpa fallback priority 2026-07-12 01:29:07 +08:00
zhaoyilun
341f08b7be docs: correct main send status and plan 2026-07-12 01:22:56 +08:00
zhaoyilun
3b01d94088 merge: a-route real chat window 2026-07-12 01:13:24 +08:00
zhaoyilun
811f760efe tools: open real offline frmP2PChat window 2026-07-11 20:58:26 +08:00
zhaoyilun
a06fe60eb2 tools: add offline chat window probe 2026-07-11 20:21:41 +08:00
zhaoyilun
c2a1ca560f tools: add offline rpa window watchdog 2026-07-11 20:04:34 +08:00
zhaoyilun
e8b4110cd2 tools: add user-silent offline rpa window mode 2026-07-11 15:11:03 +08:00
zhaoyilun
d1a1e499ea tools: add offline real client window launcher 2026-07-11 11:56:55 +08:00
zhaoyilun
8aa952a491 feat: add a-route uia send connector 2026-07-11 11:16:53 +08:00
zhaoyilun
456ef44e8e docs: publish r6f r14 release candidate status 2026-07-11 00:52:17 +08:00
zhaoyilun
caa7649feb test: add end-to-end business goals smoke 2026-07-11 00:42:45 +08:00
zhaoyilun
d94d0a318a feat: add receive message reconciliation helper 2026-07-11 00:38:06 +08:00
zhaoyilun
57db83d36c feat: add receive file download preview contract 2026-07-11 00:31:33 +08:00
zhaoyilun
6164703a9a docs: record send sent diagnostic intake 2026-07-11 00:25:54 +08:00
zhaoyilun
c8b619c254 test: add file download resolver v2 diagnostic 2026-07-11 00:20:15 +08:00
zhaoyilun
7db022469c chore: add send file sandbox evidence package 2026-07-11 00:08:56 +08:00
zhaoyilun
fab0f0f5c0 feat: harden send file idempotency audit 2026-07-10 23:02:25 +08:00
zhaoyilun
1fd10b5df7 feat: add send file preview tool 2026-07-10 21:22:39 +08:00
zhaoyilun
6781c75f84 feat: close send message production gate behavior 2026-07-10 21:01:04 +08:00
zhaoyilun
41b4b5b7d2 docs: record send sandbox evidence v2 intake 2026-07-10 20:56:46 +08:00
zhaoyilun
281b9dda2a chore: harden send sandbox evidence package v2 2026-07-10 20:51:50 +08:00
zhaoyilun
713917d929 feat: add b-route send adapter shell 2026-07-10 20:40:18 +08:00
zhaoyilun
b08b0e01bd feat: centralize send message production gate 2026-07-10 20:35:46 +08:00
zhaoyilun
2a7087218b test: add send audit idempotency diagnostics 2026-07-10 20:28:13 +08:00
zhaoyilun
dadf42817c feat: add fake send connector contract 2026-07-10 20:22:02 +08:00
zhaoyilun
9eb7f382cc docs: add continuous execution plan 2026-07-10 19:44:35 +08:00
zhaoyilun
93112d6d99 feat: harden send preview idempotency 2026-07-10 19:24:53 +08:00
zhaoyilun
138615a29c docs: accept partial send sandbox evidence 2026-07-10 19:11:52 +08:00
zhaoyilun
89d7f65b45 chore: package send recording and capability tests 2026-07-10 14:36:15 +08:00
zhaoyilun
8cdaa3c0bc chore: package send sandbox evidence gate 2026-07-10 14:19:15 +08:00
zhaoyilun
ac3f10f53c feat: add send message preview contract 2026-07-10 14:04:33 +08:00
zhaoyilun
1f99681348 feat: add send connector preflight probes 2026-07-10 13:37:08 +08:00
zhaoyilun
b2ce7b0e36 docs: analyze returned live probe evidence 2026-07-10 13:11:42 +08:00
zhaoyilun
81f23a6a54 fix: package live probe recorder compatibility variants 2026-07-10 11:34:14 +08:00
zhaoyilun
f7914a63dc feat: add iSphere live probe recorder 2026-07-10 10:59:48 +08:00
zhaoyilun
349fbe4a44 docs: record send connector selection blocker 2026-07-10 09:55:56 +08:00
zhaoyilun
5578f0bda7 feat: add file cache mapping diagnostic 2026-07-10 09:52:34 +08:00
zhaoyilun
ea38346ce3 docs: add file download mapping precheck 2026-07-10 09:44:08 +08:00
zhaoyilun
6ca6c08b62 feat: harden search ranking and deduplication 2026-07-10 09:39:18 +08:00
zhaoyilun
1d6dd2f504 docs: add receive source reconciliation precheck 2026-07-10 09:29:53 +08:00
zhaoyilun
d78152cbfa docs: replan core business capabilities 2026-07-10 09:22:28 +08:00
zhaoyilun
5a1b85906b feat: wire explicit msglib receive source 2026-07-10 09:11:22 +08:00
zhaoyilun
3f270b454f feat: select explicit msglib receive source 2026-07-10 09:04:06 +08:00
zhaoyilun
0820717767 feat: add msglib receive adapter 2026-07-10 08:59:36 +08:00
zhaoyilun
44d27879f6 feat: add msglib bounded message listing 2026-07-10 08:54:11 +08:00
zhaoyilun
c0287fa2e6 docs: design bounded db receive source 2026-07-10 08:41:51 +08:00
zhaoyilun
c8ecf5b36e feat: add msglib message source metadata 2026-07-10 08:36:34 +08:00
zhaoyilun
1a21a01184 docs: precheck msglib message source 2026-07-10 08:30:46 +08:00
zhaoyilun
78f0e47547 docs: add msglib enrichment runbook 2026-07-10 08:27:32 +08:00
zhaoyilun
07d127c44a test: add receive display msglib smoke 2026-07-10 08:24:08 +08:00
zhaoyilun
298482c25d feat: enrich receive messages with display names 2026-07-10 08:01:15 +08:00
zhaoyilun
10e7a616c6 test: add msglib mcp enrichment smoke 2026-07-10 03:13:57 +08:00
zhaoyilun
1277316663 feat: enrich search with msglib display entities 2026-07-10 03:09:37 +08:00
zhaoyilun
8be4a6a6c5 feat: add msglib display entity extraction 2026-07-10 03:02:16 +08:00
zhaoyilun
974fcaa018 feat: add msglib sidecar go client 2026-07-10 02:53:28 +08:00
zhaoyilun
5b05228ba8 feat: add msglib readonly sidecar 2026-07-10 02:45:55 +08:00
zhaoyilun
b442138e5c docs: validate msglib readonly schema path 2026-07-10 02:36:42 +08:00
zhaoyilun
8378935c90 docs: map msglib dotnet wrapper path 2026-07-10 02:29:36 +08:00
zhaoyilun
7ced6e45f6 docs: identify msglib wrapper analysis path 2026-07-10 02:17:23 +08:00
zhaoyilun
3f052125ad docs: block display name enrichment pending source evidence 2026-07-10 01:47:01 +08:00
zhaoyilun
fce1232f21 feat: validate receive files list args 2026-07-10 01:43:11 +08:00
zhaoyilun
b1891dd6cd feat: validate contact and group search args 2026-07-10 01:38:48 +08:00
zhaoyilun
95699e05b5 feat: validate receive message contract args 2026-07-10 01:32:53 +08:00
zhaoyilun
aa636f5a33 feat: filter receive messages by since 2026-07-10 01:27:27 +08:00
zhaoyilun
e7324d8aa6 feat: filter receive messages by conversation and query 2026-07-10 01:22:17 +08:00
zhaoyilun
bbffa2189d feat: align receive messages response contract 2026-07-10 01:16:47 +08:00
zhaoyilun
87a787ee1b feat: load packet logs from directory source 2026-07-10 01:09:44 +08:00
zhaoyilun
8f526e9aa9 docs: block send message pending connector evidence 2026-07-10 00:57:50 +08:00
zhaoyilun
bd03cf5d83 feat: register isphere file receive list tool 2026-07-10 00:53:04 +08:00
zhaoyilun
fe242145bb feat: extract files from message logs 2026-07-10 00:45:08 +08:00
zhaoyilun
3d68a18097 docs: confirm file receive metadata evidence 2026-07-10 00:40:02 +08:00
zhaoyilun
444c0d8ff1 feat: register isphere group search tool 2026-07-10 00:29:49 +08:00
zhaoyilun
4502a05bb4 feat: extract groups from message logs 2026-07-09 23:42:56 +08:00
zhaoyilun
7e616a1f10 docs: confirm groupchat source evidence 2026-07-09 23:39:29 +08:00
zhaoyilun
2832e286e7 feat: register isphere contact search tool 2026-07-09 23:36:10 +08:00
zhaoyilun
2621d34336 feat: extract contacts from message logs 2026-07-09 23:30:53 +08:00
zhaoyilun
878b729015 test: verify configured receive source smoke 2026-07-09 23:27:52 +08:00
zhaoyilun
28bcb94b41 feat: load packet logs from configured source 2026-07-09 23:23:06 +08:00
zhaoyilun
220f3f68c1 docs: verify five-tool mcp surface 2026-07-09 23:16:02 +08:00
zhaoyilun
57eb7a5fc7 feat: register isphere receive messages tool 2026-07-09 23:10:07 +08:00
zhaoyilun
fe2a1947f3 feat: add log-backed receive message source 2026-07-09 23:00:27 +08:00
zhaoyilun
4d5b314f83 feat: parse encrypted isphere packet logs 2026-07-09 22:53:23 +08:00
zhaoyilun
de3bb922da docs: plan stage c iterative receive loop 2026-07-09 22:46:16 +08:00
zhaoyilun
1eed0f62f2 docs: inspect n12-pre message source formats 2026-07-09 22:38:14 +08:00
zhaoyilun
57b38d8244 docs: select mcp capability sources 2026-07-09 22:29:56 +08:00
zhaoyilun
e3de9fb22e docs: index n12-pre zyl evidence sources 2026-07-09 22:26:03 +08:00
zhaoyilun
0a7b9d8596 docs: prepare mcp source discovery loop 2026-07-09 22:16:29 +08:00
zhaoyilun
e993ce15fb docs: trim safety constraints from mcp roadmap 2026-07-09 22:02:26 +08:00
zhaoyilun
cfc7787cdd docs: realign iSphere bridge around core MCP capabilities 2026-07-09 21:30:34 +08:00
石头
b2d839e1bc Merge branch 'codex/n15-report-hardening' 2026-07-09 17:30:21 +08:00
石头
fcd7a2ec53 test: harden N15 selector report verifier 2026-07-09 17:20:34 +08:00
石头
a009128e50 test: cover N15 ambiguous strict exit 2026-07-09 17:14:32 +08:00
石头
d85fae0852 docs: plan N15 selector report hardening 2026-07-09 17:06:50 +08:00
石头
2609b2028d docs: design N15 selector report hardening 2026-07-09 17:04:19 +08:00
石头
83585d6696 fix: correct N14 verifier forbidden field scan 2026-07-09 16:40:10 +08:00
石头
fc2fc04014 test: add N14 selector report verifier 2026-07-09 16:37:03 +08:00
石头
f6bc371188 feat: add N14 selector report CLI 2026-07-09 16:28:45 +08:00
石头
e1e2ca92b0 feat: render N14 selector reports 2026-07-09 16:21:33 +08:00
石头
696ee2c7ab feat: add N14 selector report model 2026-07-09 16:14:14 +08:00
石头
e487aad2a4 docs: plan N14 UIA selector report implementation 2026-07-09 16:07:15 +08:00
石头
747339ff23 docs: design N14 UIA selector report 2026-07-09 15:57:17 +08:00
szz
7b30e8e76c Merge PR #1: N12R/N13 internal sandbox handoff and UIA selector catalog
Merge N12R/N13 docs, validator fix, and offline UIA selector catalog.
2026-07-09 07:27:12 +00:00
153 changed files with 35394 additions and 230 deletions

View File

@@ -1,17 +1,17 @@
# iSphere AI Bridge
This project is for building a safe message-handoff gateway between iSphere/IMPlatformClient and AI digital employees.
This project is for building MCP core communication tools for iSphere / IMPlatformClient so AI digital employees can use contacts, groups, messages, and files through a clear tool surface.
The first goal is not full automation. The first goal is to let AI reliably see authorized messages, normalize them, generate reply drafts, and keep evidence. Sending messages is a later step and must stay behind human approval until explicitly changed.
The business goal is not a selector/report project. The business goal is an MCP tool surface for searching contacts, searching groups, receiving/reading messages, sending messages, receiving/downloading files, and sending files.
## Scope
- Authorized company communication software only.
- Read messages first; do not modify the original client state in the first POC.
- Implement the six core MCP capabilities: search contacts, search groups, receive/read messages, send messages, receive/download files, and send files.
- Build from stable capability sources first, then fill gaps with UI automation only where it directly serves a core tool.
- Prefer stable entry points in this order:
1. Local data/log/cache discovery.
2. Plugin or existing bridge API.
3. UI automation/RPA wrapper.
1. Existing bridge / API / local service.
2. Local data/log/cache discovery in read-only mode.
3. UI automation/RPA wrapper as fallback.
4. Network/API discovery.
5. Binary reverse engineering only when needed to explain or validate the above.
@@ -28,12 +28,9 @@ The first goal is not full automation. The first goal is to let AI reliably see
- `prompts/` - ready-to-use Codex prompts for each investigation phase.
- `evidence/` - local evidence output; ignored by Git except `.gitkeep`.
- `runs/` - run logs and temporary investigation outputs; ignored by Git except `.gitkeep`.
- `src/` - future implementation code.
- `cmd/`, `internal/`, `native/` - current Go MCP server, helper client, tool registration, and C# WinHelper code.
## Safety Baseline
## Operating Notes
- Do not bypass login, licensing, MFA, access control, TLS protections, or endpoint security.
- Do not read non-authorized account data.
- Do not send real messages without explicit human approval.
- Do not modify production databases, logs, config files, registry keys, or binaries during discovery.
- Redact secrets, tokens, private message content, and personal data in reports.
- `runs/` and `evidence/` are local evidence/work directories and are not part of normal commits.
- Route work through the MCP core tool contract instead of expanding selector/report-only nodes.

View File

@@ -0,0 +1,673 @@
package main
import (
"context"
"crypto/cipher"
"crypto/des"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/mcpserver"
)
var expectedTools = []string{
"win_helper_version",
"win_helper_self_check",
"win_helper_scan_windows",
"win_helper_dump_uia",
"isphere_receive_messages",
"isphere_search_contacts",
"isphere_search_groups",
"isphere_receive_files",
"isphere_send_file",
"isphere_send_message",
}
type smokeResult struct {
OK bool `json:"ok"`
Package string `json:"package"`
Transport string `json:"transport"`
Initialized bool `json:"initialized"`
ToolCount int `json:"tool_count"`
Tools []string `json:"tools"`
HelperName string `json:"helper_name"`
ReceiveMessageCount int `json:"receive_message_count"`
ContactCount int `json:"contact_count"`
GroupCount int `json:"group_count"`
FileCount int `json:"file_count"`
FixtureReceiveMessageCount int `json:"fixture_receive_message_count"`
FixtureContactCount int `json:"fixture_contact_count"`
FixtureGroupCount int `json:"fixture_group_count"`
FixtureFileCount int `json:"fixture_file_count"`
FixtureDirReceiveMessageCount int `json:"fixture_dir_receive_message_count"`
FixtureDirContactCount int `json:"fixture_dir_contact_count"`
FixtureDirGroupCount int `json:"fixture_dir_group_count"`
FixtureDirFileCount int `json:"fixture_dir_file_count"`
SendPreviewToolPresent bool `json:"send_preview_tool_present"`
ProductionSendEnabled bool `json:"production_send_enabled"`
SendFilePreviewToolPresent bool `json:"send_file_preview_tool_present"`
SendFileProductionEnabled bool `json:"send_file_production_enabled"`
LocalLoginRequired bool `json:"local_login_required"`
RealISphereLoginRequired bool `json:"real_isphere_login_required"`
PythonRuntimeRequired bool `json:"python_runtime_required"`
NoRealSend bool `json:"no_real_send"`
ActionToolsPresent bool `json:"action_tools_present"`
PacketLogFileConfiguredAfterRun bool `json:"packet_log_file_configured_after_run"`
PacketLogDirConfiguredAfterRun bool `json:"packet_log_dir_configured_after_run"`
MsgLibConfigured bool `json:"msglib_configured"`
SendAuditPath string `json:"send_audit_path"`
SendIdempotencyPath string `json:"send_idempotency_path"`
}
func main() {
if err := run(); err != nil {
fail("capability_smoke", err)
}
}
func run() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
clearRuntimeEnv()
sendStateDir := filepath.Join("runs", "capability-smoke", fmt.Sprintf("state-%d", time.Now().UnixNano()))
if err := os.MkdirAll(sendStateDir, 0o755); err != nil {
return err
}
auditPath := filepath.Join(sendStateDir, "send-preview.jsonl")
if err := os.Setenv("ISPHERE_SEND_AUDIT_PATH", auditPath); err != nil {
return err
}
idempotencyPath := filepath.Join(sendStateDir, "send-idempotency.jsonl")
if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", idempotencyPath); err != nil {
return err
}
sendFileDir := filepath.Join(sendStateDir, "send-file-allowed")
if err := os.MkdirAll(sendFileDir, 0o755); err != nil {
return err
}
sendFilePath := filepath.Join(sendFileDir, "redacted-preview.txt")
if err := os.WriteFile(sendFilePath, []byte("hello"), 0o600); err != nil {
return err
}
if err := os.Setenv("ISPHERE_SEND_FILE_ALLOWED_DIR", sendFileDir); err != nil {
return err
}
session, cleanup, err := newSession(ctx, "isphere-capability-smoke")
if err != nil {
return err
}
defer cleanup()
toolNames, err := listToolNames(ctx, session)
if err != nil {
return err
}
if err := requireExpectedTools(toolNames); err != nil {
return err
}
helperName, err := verifyHelperVersion(ctx, session)
if err != nil {
return err
}
receiveCount, err := verifyReceiveMessages(ctx, session, 0)
if err != nil {
return err
}
contactCount, err := verifySearchContacts(ctx, session, "sender", 0)
if err != nil {
return err
}
groupCount, err := verifySearchGroups(ctx, session, "project", 0)
if err != nil {
return err
}
fileCount, err := verifyReceiveFiles(ctx, session, "report", 0, "", "")
if err != nil {
return err
}
sendPreviewOK, productionSendEnabled, err := verifySendMessage(ctx, session)
if err != nil {
return err
}
sendFilePreviewOK, sendFileProductionEnabled, err := verifySendFile(ctx, session, sendFilePath)
if err != nil {
return err
}
fixtureReceiveCount, fixtureContactCount, fixtureGroupCount, fixtureFileCount, err := verifyPacketLogFileFixture(ctx)
if err != nil {
return err
}
fixtureDirReceiveCount, fixtureDirContactCount, fixtureDirGroupCount, fixtureDirFileCount, err := verifyPacketLogDirFixture(ctx)
if err != nil {
return err
}
result := smokeResult{
OK: true,
Package: "send-capability-test",
Transport: "sdk_in_memory",
Initialized: true,
ToolCount: len(toolNames),
Tools: toolNames,
HelperName: helperName,
ReceiveMessageCount: receiveCount,
ContactCount: contactCount,
GroupCount: groupCount,
FileCount: fileCount,
FixtureReceiveMessageCount: fixtureReceiveCount,
FixtureContactCount: fixtureContactCount,
FixtureGroupCount: fixtureGroupCount,
FixtureFileCount: fixtureFileCount,
FixtureDirReceiveMessageCount: fixtureDirReceiveCount,
FixtureDirContactCount: fixtureDirContactCount,
FixtureDirGroupCount: fixtureDirGroupCount,
FixtureDirFileCount: fixtureDirFileCount,
SendPreviewToolPresent: sendPreviewOK,
ProductionSendEnabled: productionSendEnabled,
SendFilePreviewToolPresent: sendFilePreviewOK,
SendFileProductionEnabled: sendFileProductionEnabled,
LocalLoginRequired: false,
RealISphereLoginRequired: false,
PythonRuntimeRequired: false,
NoRealSend: true,
ActionToolsPresent: false,
PacketLogFileConfiguredAfterRun: os.Getenv("ISPHERE_PACKET_LOG_FILE") != "",
PacketLogDirConfiguredAfterRun: os.Getenv("ISPHERE_PACKET_LOG_DIR") != "",
MsgLibConfigured: false,
SendAuditPath: auditPath,
SendIdempotencyPath: idempotencyPath,
}
encoded, err := json.Marshal(result)
if err != nil {
return err
}
fmt.Println(string(encoded))
return nil
}
func clearRuntimeEnv() {
for _, key := range []string{
"ISPHERE_PACKET_LOG_FILE",
"ISPHERE_PACKET_LOG_DIR",
"ISPHERE_MSGLIB_SIDECAR_EXE",
"ISPHERE_MSGLIB_SQLITE_DLL",
"ISPHERE_MSGLIB_DB",
"ISPHERE_MSGLIB_PASSWORD",
"ISPHERE_SEND_FILE_ALLOWED_DIR",
} {
_ = os.Unsetenv(key)
}
}
func newSession(ctx context.Context, name string) (*mcp.ClientSession, func(), error) {
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server, err := mcpserver.NewServerFromEnv()
if err != nil {
return nil, nil, fmt.Errorf("server config: %w", err)
}
serverCtx, cancel := context.WithCancel(ctx)
serverErr := make(chan error, 1)
go func() {
serverErr <- server.Run(serverCtx, serverTransport)
}()
client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "0.1.0"}, nil)
session, err := client.Connect(serverCtx, clientTransport, nil)
if err != nil {
cancel()
return nil, nil, fmt.Errorf("connect: %w", err)
}
cleanup := func() {
_ = session.Close()
cancel()
select {
case <-serverErr:
case <-time.After(2 * time.Second):
fmt.Fprintln(os.Stderr, `{"ok":false,"step":"server_shutdown","error":"server did not stop before timeout"}`)
}
}
return session, cleanup, nil
}
func listToolNames(ctx context.Context, session *mcp.ClientSession) ([]string, error) {
result, err := session.ListTools(ctx, &mcp.ListToolsParams{})
if err != nil {
return nil, fmt.Errorf("list tools: %w", err)
}
names := make([]string, 0, len(result.Tools))
for _, tool := range result.Tools {
names = append(names, tool.Name)
}
sort.Strings(names)
return names, nil
}
func requireExpectedTools(got []string) error {
want := append([]string(nil), expectedTools...)
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
return fmt.Errorf("tool names = %#v, want %#v", got, want)
}
return nil
}
func verifyHelperVersion(ctx context.Context, session *mcp.ClientSession) (string, error) {
payload, err := callToolObject(ctx, session, "win_helper_version", map[string]any{})
if err != nil {
return "", err
}
if payload["ok"] != true {
return "", fmt.Errorf("win_helper_version ok = %#v, want true", payload["ok"])
}
data, ok := payload["data"].(map[string]any)
if !ok {
return "", fmt.Errorf("win_helper_version data missing: %#v", payload["data"])
}
helperName, _ := data["helper_name"].(string)
if helperName != "ISphereWinHelper" {
return "", fmt.Errorf("helper_name = %q, want ISphereWinHelper", helperName)
}
return helperName, nil
}
func verifyReceiveMessages(ctx context.Context, session *mcp.ClientSession, wantCount int) (int, error) {
payload, err := callToolObject(ctx, session, "isphere_receive_messages", map[string]any{
"source_preference": "local_readonly",
"preview": true,
"cursor": "",
"include_attachment_metadata": true,
"limit": 5,
})
if err != nil {
return 0, err
}
if payload["ok"] != true {
return 0, fmt.Errorf("isphere_receive_messages ok = %#v, want true", payload["ok"])
}
messages, ok := payload["messages"].([]any)
if !ok {
return 0, fmt.Errorf("messages missing or not array: %#v", payload["messages"])
}
if len(messages) != wantCount {
return 0, fmt.Errorf("message count = %d, want %d", len(messages), wantCount)
}
return len(messages), nil
}
func verifySearchContacts(ctx context.Context, session *mcp.ClientSession, query string, wantCount int) (int, error) {
payload, err := callToolObject(ctx, session, "isphere_search_contacts", map[string]any{
"query": query,
"source_preference": "local_readonly",
"include_inactive": true,
"cursor": "",
"limit": 5,
})
if err != nil {
return 0, err
}
if payload["ok"] != true {
return 0, fmt.Errorf("isphere_search_contacts ok = %#v, want true", payload["ok"])
}
contacts, ok := payload["contacts"].([]any)
if !ok {
return 0, fmt.Errorf("contacts missing or not array: %#v", payload["contacts"])
}
if len(contacts) != wantCount {
return 0, fmt.Errorf("contact count = %d, want %d", len(contacts), wantCount)
}
return len(contacts), nil
}
func verifySearchGroups(ctx context.Context, session *mcp.ClientSession, query string, wantCount int) (int, error) {
payload, err := callToolObject(ctx, session, "isphere_search_groups", map[string]any{
"query": query,
"source_preference": "local_readonly",
"include_archived": true,
"cursor": "",
"limit": 5,
})
if err != nil {
return 0, err
}
if payload["ok"] != true {
return 0, fmt.Errorf("isphere_search_groups ok = %#v, want true", payload["ok"])
}
groups, ok := payload["groups"].([]any)
if !ok {
return 0, fmt.Errorf("groups missing or not array: %#v", payload["groups"])
}
if len(groups) != wantCount {
return 0, fmt.Errorf("group count = %d, want %d", len(groups), wantCount)
}
return len(groups), nil
}
func verifyReceiveFiles(ctx context.Context, session *mcp.ClientSession, nameContains string, wantCount int, wantFileID string, wantFileName string) (int, error) {
payload, err := callToolObject(ctx, session, "isphere_receive_files", map[string]any{
"mode": "list",
"name_contains": nameContains,
"source_preference": "local_readonly",
"preview": true,
"cursor": "",
"output_dir": "",
"limit": 5,
})
if err != nil {
return 0, err
}
if payload["ok"] != true {
return 0, fmt.Errorf("isphere_receive_files ok = %#v, want true", payload["ok"])
}
files, ok := payload["files"].([]any)
if !ok {
return 0, fmt.Errorf("files missing or not array: %#v", payload["files"])
}
if len(files) != wantCount {
return 0, fmt.Errorf("file count = %d, want %d", len(files), wantCount)
}
if wantCount > 0 {
first, ok := files[0].(map[string]any)
if !ok {
return 0, fmt.Errorf("first file not object: %#v", files[0])
}
if first["file_id"] != wantFileID || first["file_name"] != wantFileName {
return 0, fmt.Errorf("unexpected file: %#v", first)
}
if first["download_ref"] != nil || first["saved_path"] != nil || first["sha256"] != nil {
return 0, fmt.Errorf("file list should not download or save: %#v", first)
}
}
return len(files), nil
}
func verifySendMessage(ctx context.Context, session *mcp.ClientSession) (bool, bool, error) {
content := "redacted preview body"
contentHash := sha256Hex(content)
preview, err := callToolObject(ctx, session, "isphere_send_message", map[string]any{
"target_type": "direct",
"target_id": "sender@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "capability-preview-idempotency-key",
"execution_mode": "preview",
})
if err != nil {
return false, false, err
}
if preview["ok"] != true || preview["send_status"] != "planned" || preview["connector_stage"] != "preview" {
return false, false, fmt.Errorf("unexpected send preview payload: %#v", preview)
}
sideEffects, ok := preview["side_effects"].(map[string]any)
if !ok || sideEffects["sent_message"] != false || sideEffects["clicked_ui"] != false || sideEffects["typed_text"] != false {
return false, false, fmt.Errorf("send preview side effects mismatch: %#v", preview["side_effects"])
}
production, err := callToolObject(ctx, session, "isphere_send_message", map[string]any{
"target_type": "direct",
"target_id": "sender@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "capability-production-idempotency-key",
"execution_mode": "production",
})
if err != nil {
return false, false, err
}
if production["ok"] != false || production["send_status"] != "blocked" || production["connector_stage"] != "blocked" {
return false, false, fmt.Errorf("production send was not blocked: %#v", production)
}
productionSideEffects, ok := production["side_effects"].(map[string]any)
if !ok || productionSideEffects["sent_message"] != false || productionSideEffects["uploaded_file"] != false || productionSideEffects["captured_network"] != false {
return false, false, fmt.Errorf("production side effects mismatch: %#v", production["side_effects"])
}
return true, false, nil
}
func verifySendFile(ctx context.Context, session *mcp.ClientSession, filePath string) (bool, bool, error) {
fileHash, err := sha256FileHex(filePath)
if err != nil {
return false, false, err
}
preview, err := callToolObject(ctx, session, "isphere_send_file", map[string]any{
"target_type": "direct",
"target_id": "sender@imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": fileHash,
"idempotency_key": "capability-file-preview-idempotency-key",
"execution_mode": "preview",
"preview": true,
})
if err != nil {
return false, false, err
}
if preview["ok"] != true || preview["send_status"] != "planned" || preview["file_sha256"] != fileHash || preview["file_size_bytes"] != float64(5) {
return false, false, fmt.Errorf("unexpected send-file preview payload: %#v", preview)
}
sideEffects, ok := preview["side_effect_flags"].(map[string]any)
if !ok || sideEffects["sent_file"] != false || sideEffects["uploaded_file"] != false || sideEffects["clicked_ui"] != false {
return false, false, fmt.Errorf("send-file preview side effects mismatch: %#v", preview["side_effect_flags"])
}
if preview["production_send_enabled"] != false || preview["real_send_attempted"] != false {
return false, false, fmt.Errorf("send-file preview should not enable or attempt send: %#v", preview)
}
production, err := callToolObject(ctx, session, "isphere_send_file", map[string]any{
"target_type": "direct",
"target_id": "sender@imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": fileHash,
"idempotency_key": "capability-file-production-idempotency-key",
"execution_mode": "production",
})
if err != nil {
return false, false, err
}
if production["ok"] != false || production["send_status"] != "blocked" || production["production_send_enabled"] != false || production["real_send_attempted"] != false {
return false, false, fmt.Errorf("production send-file was not blocked: %#v", production)
}
productionSideEffects, ok := production["side_effect_flags"].(map[string]any)
if !ok || productionSideEffects["sent_file"] != false || productionSideEffects["uploaded_file"] != false || productionSideEffects["captured_network"] != false {
return false, false, fmt.Errorf("production send-file side effects mismatch: %#v", production["side_effect_flags"])
}
return true, false, nil
}
func verifyPacketLogFileFixture(ctx context.Context) (int, int, int, int, error) {
plaintext := fixturePacketLogPlaintext("msg-fixture-1", "redacted-report.docx")
line, err := encryptPacketLogLine(plaintext)
if err != nil {
return 0, 0, 0, 0, err
}
file, err := os.CreateTemp("", "isphere-capability-packet-*.log")
if err != nil {
return 0, 0, 0, 0, err
}
fixturePath := file.Name()
defer os.Remove(fixturePath)
if _, err := file.WriteString("\n" + line + "\n"); err != nil {
_ = file.Close()
return 0, 0, 0, 0, err
}
if err := file.Close(); err != nil {
return 0, 0, 0, 0, err
}
restore := replaceEnv("ISPHERE_PACKET_LOG_FILE", fixturePath)
defer restore()
_ = os.Unsetenv("ISPHERE_PACKET_LOG_DIR")
session, cleanup, err := newSession(ctx, "isphere-capability-smoke-file-fixture")
if err != nil {
return 0, 0, 0, 0, err
}
defer cleanup()
receiveCount, err := verifyReceiveMessages(ctx, session, 1)
if err != nil {
return 0, 0, 0, 0, err
}
contactCount, err := verifySearchContacts(ctx, session, "sender", 1)
if err != nil {
return 0, 0, 0, 0, err
}
groupCount, err := verifySearchGroups(ctx, session, "project", 1)
if err != nil {
return 0, 0, 0, 0, err
}
fileCount, err := verifyReceiveFiles(ctx, session, "report", 1, "msg-fixture-1:redacted-report.docx", "redacted-report.docx")
if err != nil {
return 0, 0, 0, 0, err
}
return receiveCount, contactCount, groupCount, fileCount, nil
}
func verifyPacketLogDirFixture(ctx context.Context) (int, int, int, int, error) {
plaintext := fixturePacketLogPlaintext("msg-dir-fixture-1", "redacted-dir-report.docx")
line, err := encryptPacketLogLine(plaintext)
if err != nil {
return 0, 0, 0, 0, err
}
dir, err := os.MkdirTemp("", "isphere-capability-packet-dir-*")
if err != nil {
return 0, 0, 0, 0, err
}
defer os.RemoveAll(dir)
if err := os.WriteFile(filepath.Join(dir, "a-packet-reader.log"), []byte("\n"+line+"\n"), 0o600); err != nil {
return 0, 0, 0, 0, err
}
if err := os.WriteFile(filepath.Join(dir, "ignored.tmp"), []byte("ignored\n"), 0o600); err != nil {
return 0, 0, 0, 0, err
}
restoreFile := replaceEnv("ISPHERE_PACKET_LOG_FILE", "")
restoreDir := replaceEnv("ISPHERE_PACKET_LOG_DIR", dir)
defer restoreFile()
defer restoreDir()
session, cleanup, err := newSession(ctx, "isphere-capability-smoke-dir-fixture")
if err != nil {
return 0, 0, 0, 0, err
}
defer cleanup()
receiveCount, err := verifyReceiveMessages(ctx, session, 1)
if err != nil {
return 0, 0, 0, 0, err
}
contactCount, err := verifySearchContacts(ctx, session, "sender", 1)
if err != nil {
return 0, 0, 0, 0, err
}
groupCount, err := verifySearchGroups(ctx, session, "project", 1)
if err != nil {
return 0, 0, 0, 0, err
}
fileCount, err := verifyReceiveFiles(ctx, session, "dir-report", 1, "msg-dir-fixture-1:redacted-dir-report.docx", "redacted-dir-report.docx")
if err != nil {
return 0, 0, 0, 0, err
}
return receiveCount, contactCount, groupCount, fileCount, nil
}
func callToolObject(ctx context.Context, session *mcp.ClientSession, name string, args map[string]any) (map[string]any, error) {
result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
if result.IsError {
return nil, fmt.Errorf("%s returned IsError=true: %#v", name, result.Content)
}
payload, err := json.Marshal(result.StructuredContent)
if err != nil {
return nil, fmt.Errorf("%s marshal structuredContent: %w", name, err)
}
var object map[string]any
if err := json.Unmarshal(payload, &object); err != nil {
return nil, fmt.Errorf("%s decode structuredContent %s: %w", name, payload, err)
}
return object, nil
}
func fixturePacketLogPlaintext(messageID string, body string) string {
return "--------------------------------------------------------------------------------------------------------------------------------------------\n" +
"2026/7/7 15:30:07\n" +
"<message id=\"" + messageID + "\" from=\"project-room@conference.imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"sender@imopenfire1-lanzhou\" type=\"groupchat\">\n" +
" <body>" + body + "</body>\n" +
" <received xmlns=\"urn:xmpp:receipts\" id=\"receipt-" + messageID + "\" type=\"1\" stamp=\"\" />\n" +
" <subject>FILE_TRANSFER_CANCEL</subject>\n" +
" <isphere xmlns=\"isphere.im\" type=\"1001\" sendtime=\"1783423807000\" version=\"1\" />\n" +
" <readed />\n" +
"</message>"
}
func encryptPacketLogLine(plaintext string) (string, error) {
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
return "", err
}
plain := padPKCS7([]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 padPKCS7(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 sha256Hex(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func sha256FileHex(path string) (string, error) {
payload, err := os.ReadFile(path)
if err != nil {
return "", err
}
return sha256Hex(string(payload)), nil
}
func replaceEnv(key string, value string) func() {
oldValue, hadOldValue := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
return func() {
if hadOldValue {
_ = os.Setenv(key, oldValue)
} else {
_ = os.Unsetenv(key)
}
}
}
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)
}

View File

@@ -15,7 +15,10 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
server := mcpserver.NewServer()
server, err := mcpserver.NewServerFromEnv()
if err != nil {
log.Fatalf("configure isphere mcp server: %v", err)
}
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatalf("isphere mcp server stopped: %v", err)
}

View File

@@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"io"
"os"
"time"
"isphere-ai-bridge/internal/uiaselector"
)
func main() {
os.Exit(Run(os.Args[1:], os.Stdout, os.Stderr))
}
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
flags := flag.NewFlagSet("uia-selector-report", flag.ContinueOnError)
flags.SetOutput(stderr)
dumpPath := flags.String("dump", "", "UIA dump JSON path")
outJSON := flags.String("out-json", "", "JSON report output path")
outMD := flags.String("out-md", "", "Markdown report output path")
strict := flags.Bool("strict", false, "return non-zero for unmatched, ambiguous, or invalid selector reports")
if err := flags.Parse(args); err != nil {
return uiaselector.StrictExitReportGenerationFailure
}
if *dumpPath == "" {
fmt.Fprintln(stderr, "missing -dump")
return uiaselector.StrictExitReportGenerationFailure
}
root, err := uiaselector.LoadDumpFile(*dumpPath)
if err != nil {
fmt.Fprintf(stderr, "load dump: %v\n", err)
return uiaselector.StrictExitReportGenerationFailure
}
report := uiaselector.RunCatalogReport(*dumpPath, root, uiaselector.DefaultCatalog(), time.Now().UTC())
if *outJSON != "" {
if err := uiaselector.WriteReportJSON(report, *outJSON); err != nil {
fmt.Fprintf(stderr, "write JSON report: %v\n", err)
return uiaselector.StrictExitReportGenerationFailure
}
}
if *outMD != "" {
if err := uiaselector.WriteReportMarkdown(report, *outMD); err != nil {
fmt.Fprintf(stderr, "write Markdown report: %v\n", err)
return uiaselector.StrictExitReportGenerationFailure
}
}
code := uiaselector.StrictExitCode(report)
if *strict && code != uiaselector.StrictExitOK {
fmt.Fprintf(stderr, "strict report check failed with code %d\n", code)
return code
}
if !report.OK {
fmt.Fprintf(stderr, "report generation failed: %#v\n", report.Error)
return uiaselector.StrictExitReportGenerationFailure
}
fmt.Fprintf(stdout, "N14 selector report generated: catalog=%d matched=%d unmatched=%d ambiguous=%d\n", report.CatalogSize, report.MatchedCount, report.UnmatchedCount, report.AmbiguousCount)
return uiaselector.StrictExitOK
}

View File

@@ -0,0 +1,63 @@
package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunWritesJSONAndMarkdownReports(t *testing.T) {
tmp := t.TempDir()
jsonPath := filepath.Join(tmp, "report.json")
mdPath := filepath.Join(tmp, "report.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run([]string{
"-dump", "../../internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json",
"-out-json", jsonPath,
"-out-md", mdPath,
"-strict",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("Run exit code = %d, stderr=%s stdout=%s", code, stderr.String(), stdout.String())
}
jsonBody, err := os.ReadFile(jsonPath)
if err != nil {
t.Fatalf("read JSON report: %v", err)
}
var parsed struct {
OK bool `json:"ok"`
CatalogSize int `json:"catalog_size"`
MatchedCount int `json:"matched_count"`
UnmatchedCount int `json:"unmatched_count"`
AmbiguousCount int `json:"ambiguous_count"`
}
if err := json.Unmarshal(jsonBody, &parsed); err != nil {
t.Fatalf("JSON report did not parse: %v", err)
}
if !parsed.OK || parsed.CatalogSize != 10 || parsed.MatchedCount != 10 || parsed.UnmatchedCount != 0 || parsed.AmbiguousCount != 0 {
t.Fatalf("unexpected JSON report summary: %#v", parsed)
}
markdown, err := os.ReadFile(mdPath)
if err != nil {
t.Fatalf("read Markdown report: %v", err)
}
if !strings.Contains(string(markdown), "## Summary") {
t.Fatalf("Markdown report missing Summary: %s", string(markdown))
}
}
func TestRunRequiresDumpPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run([]string{"-strict"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("Run exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "missing -dump") {
t.Fatalf("stderr missing dump message: %s", stderr.String())
}
}

419
docs/current-status-card.md Normal file
View File

@@ -0,0 +1,419 @@
# Current Status Card
Date: 2026-07-12
Branch: `main`
Loop base: `e993ce1 docs: trim safety constraints from mcp roadmap`
Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-report-hardening'`
## Business direction
项目核心不是继续做 UIA selector/report而是通过 MCP 暴露 iSphere 的联系人、群组、消息、文件能力。
后续研发主线切换为 MCP 核心通信能力:搜索联系人、搜索群组、收发消息、收发文件。现有 UIA/selector/report 只作为底层辅助和证据,不再作为主线继续扩展。
Route priority is explicit:
1. Primary route: B-route running-client sidecar / in-process connector / existing API that reuses the logged-in `IMPlatformClient.exe` runtime.
2. Secondary route: existing bridge, local service, local data, log, cache, or database source where it can satisfy the MCP contract.
3. Backup route only: A-route/RPA/UIA. Use it to keep the product moving when the B-route is blocked or temporarily untestable, but do not promote it above B-route as the default product architecture.
## Current completed state
- Go MCP foundation exists: `cmd/isphere-mcp`, `internal/mcpserver`, `internal/helperclient`, and `internal/tools` build a stdio MCP server.
- The current MCP surface exposes ten tools: four WinHelper observation tools, four read-oriented business tools, one send-message tool that is preview by default and can use explicit A-route UIA action config, and one send-file preview-only tool:
- `win_helper_version`
- `win_helper_self_check`
- `win_helper_scan_windows`
- `win_helper_dump_uia`
- `isphere_receive_messages`
- `isphere_search_contacts`
- `isphere_search_groups`
- `isphere_receive_files`
- `isphere_send_message` preview/dry-run by default; explicit `uia_rpa` config can perform UI write/click, but business delivery remains unverified
- `isphere_send_file` preview/dry-run only; production file upload is blocked
- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract. Helper `0.5.0` includes read-only `probe_send_entrypoints` / `probe_send_uia_controls` plus `uia_send_message` for configured UI Automation write/click.
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, ten-tool MCP surface, default empty-source receive/contact/group/file calls, standard send-message preview/production-blocked behavior when A-route env is not configured, send-file preview/production-blocked behavior, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
- `isphere_receive_messages` now returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope while retaining legacy parser-native message fields for compatibility.
- `isphere_receive_messages` accepts the read-contract argument set for the current local-readonly path: `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, `source_preference`, `preview`, and empty `cursor`.
- `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path and can optionally enrich results from the env-configured C29 `MsgLib.db` display-entity reader; default behavior remains log/JID-backed when MsgLib env vars are absent.
- `isphere_receive_files` list mode accepts and validates the safe file-list contract args for the current local-readonly path while keeping download blocked.
- `isphere_send_message` accepts the R6b/R6e preview/dry-run contract (`target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode`) and returns planned preview metadata plus redacted audit/idempotency metadata; `execution_mode="production"` returns blocked status with all side-effect flags false.
- A-route fallback can now be explicitly enabled with `ISPHERE_SEND_CONNECTOR_MODE=uia_rpa` and a real/synthetic chat HWND. In that mode the helper can write text to `rtbSendMessage` and click `btnSend`; this is a UI action capability, not proof of business delivery.
- Offline/unlogged real-window work can open the real `frmP2PChat` and confirm UIA root `frmP2PChat`, editor `rtbSendMessage`, button `btnSend`, and receive area `rtbRecvMessage`. The local offline client still reports that messages cannot be sent while offline.
- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks, bounded contact/group display-entity reads, message-source metadata, and sidecar-level bounded message listing; `internal/msglib` wraps it from Go, C30 wires display metadata as an optional MCP contact/group enrichment source, C39 adds a Go receive-source adapter around `ListMessages`, C40 adds tool-level explicit `msglib_readonly` source selection, and C41 wires it through env configuration without changing the MCP default source.
- 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.
## What cannot be claimed externally
- The project has not yet implemented production message sending, file download, or production file sending; `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode have operator-local encrypted log-file/log-directory support but not a production ingestion path.
- The project has not completed production iSphere integration.
- The project has not proven a stable real message source for the full business workflow beyond decrypted PacketReader log evidence and synthetic/redacted tests.
- The project has not proven business-level message delivery or real file upload. A-route can perform UI write/click when explicitly configured, but local offline clicks do not prove server send, receiver delivery, success/ack, or sent-record evidence.
- N12-pre offline evidence is not N12 pass.
- N13/N14/N15 selector/report work is pre-business evidence, not finished contact/message/file capability.
## What can be claimed externally
- The repository has a Go MCP + C# WinHelper foundation for controlled Windows-side read-only observation.
- The helper protocol boundary is explicit and keeps Windows/UIA work separate from the Go MCP service layer.
- The current code can support future MCP business tools by routing low-level Windows/UIA fallback work through a bounded helper layer.
- The project has repeatable verification scripts for the helper/MCP foundation and the current ten-tool surface.
- The project now has a corrected business contract and plan for core MCP communication tools, plus registered receive/contact/group/file-list read tools that can use `ISPHERE_PACKET_LOG_FILE` for one operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` for an operator-local directory of encrypted `.log`/`.txt` PacketReader files.
## N13/N14/N15 positioning
N13/N14/N15 are pre-business validation results. They can help identify UI elements and validate reports if UI automation becomes necessary, but they are not the product route. New work should first validate real business capability sources and core MCP tool contracts.
## Current loop
Current loop: `A-route real chat window merged to main`; next step is evidence-driven B-route send/file production verification, with A-route/RPA kept as backup readiness.
Active continuous execution plan: `docs/superpowers/plans/2026-07-11-a-route-rpa-send.md`.
Previous roadmap file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`.
Current business status correction: `docs/reports/2026-07-12-main-business-status-correction.md`.
Loop rule:
1. Write or update the plan before coding.
2. Implement one loop only.
3. Verify, commit exact paths, then update the next loop based on actual results.
4. Continue automatically while the next step is clear; ask the user only when blocked or when business direction changes.
Current loop output so far:
1. C1: `internal/isphere/logcodec.DecryptLine` and `internal/isphere/packetlog.ParseMessageLog` for redacted PacketReader message logs.
2. C2: `internal/isphere.EncryptedPacketLogSource.ReceiveMessages` source abstraction with synthetic/redacted encrypted fixtures.
3. C3: `isphere_receive_messages` MCP tool registration and server tool list update.
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.
8. C8: `isphere_search_contacts` MCP registration and six-tool verification.
9. C9: group source evidence precheck confirmed groupchat/conference/MUC hits in ignored decrypted logs.
10. C10: log-backed group candidate extraction from groupchat/conference JIDs.
11. C11: `isphere_search_groups` MCP registration and seven-tool verification.
12. C12: file evidence precheck confirmed enough PacketReader/Smark evidence for file metadata listing, but not enough for real cache/download mapping.
13. C13: `internal/isphere.ListFilesFromMessages` extracts synthetic/redacted file metadata candidates from normalized messages.
14. C14: `isphere_receive_files` MCP list mode registered and verify-go-mcp now covers eight tools plus default/configured file-list smoke.
15. C15: `isphere_send_message` source precheck found no validated write connector; send implementation is blocked pending bridge/API, UIA action, or protocol evidence.
16. C16: configured PacketReader source hardening added `ISPHERE_PACKET_LOG_DIR`, deterministic recursive `.log`/`.txt` directory loading, ambiguous file/dir config failure, and configured-directory receive/contact/group/file smoke verification.
17. C17: `isphere_receive_messages` output was aligned to the core read contract envelope and message fields while retaining legacy aliases; verify-go-mcp now asserts contract fields for configured file and directory source smokes.
18. C18: `isphere_receive_messages` gained `conversation_id` and `query` filters across source, MCP args, tests, and configured file/directory smoke verification.
19. C19: `isphere_receive_messages` gained RFC3339 `since` filtering against PacketReader epoch-millisecond timestamps plus invalid-since error handling.
20. C20: `isphere_receive_messages` now accepts and validates the remaining safe read-contract args, rejects unsupported source/cursor cases, and can suppress inline attachment metadata.
21. C21: `isphere_search_contacts` and `isphere_search_groups` now accept and validate safe search-contract args, while rejecting unsupported source/cursor cases.
22. C22: `isphere_receive_files` list mode now accepts and validates safe contract args, rejects unsupported source/cursor/output/download cases, and keeps download blocked.
23. C23: display-name enrichment was blocked because current committed evidence and ignored metadata do not validate a JID-to-display-name/group-title source; `MsgLib.db` remains the most likely next source but inspected copies are not directly readable as SQLite.
24. C24: `MsgLib.db` readability precheck found high-entropy non-SQLite files with a consistent wrapper/encryption signature; wrapper candidates include `HYHC.IMPP.DAL.dll`, `Utilities.Lib.SQLiteInteraction*.dll`, `Utilities.Lib.DBModel.dll`, and `RepairDatabase.exe`.
25. C25: static .NET wrapper analysis recovered the DAL open path: `HYHC.IMPP.DAL.IMPPDAL` configures `System.Data.SQLite` through `Utilities.Lib.DBSQLite`, sets `DatabaseName` to the DB path and `Password` to `123`, and exposes candidate display/message tables such as `TD_Roster`, `tblRecent`, `tblPersonMsg`, `tblMsgGroupPersonMsg`, and `TD_WorkGroupAuth`.
26. C26: copied `MsgLib.db` schema extraction succeeded through a 32-bit .NET `System.Data.SQLite` read-only probe with password `123`; all three copied DB candidates opened, with confirmed contact/group/message/file schema tables.
27. C27: added `native/MsgLibReadSidecar` with protocol `isphere.msglib.v1`, x86 build/verify scripts, and safe `self_check`, `schema_summary`, and `display_sources` operations; evidence-backed smoke returned 8 display-source candidates without row/message values.
28. C28: added `internal/msglib` Go client wrapper for the sidecar protocol with env config, fake-sidecar unit tests, timeout handling, structured sidecar errors, `SelfCheck`, and `DisplaySources`; no MCP tool wiring or message/display row reads yet.
29. C29: added bounded `display_entities` support across the x86 sidecar, Go client, and verification script; evidence-backed verification returned sanitized contact/group counts and source-table names only, with no message bodies, file paths, raw rows, sends, writes, hooks, or injection.
30. C30: wired `DisplayEntities` into `isphere_search_contacts` and `isphere_search_groups` as an optional env-configured enrichment source with fake-source tests; default MCP verification now clears MsgLib env to keep the standard smoke deterministic.
31. C31: added optional `scripts/verify-msglib-mcp-enrichment.ps1`; real copied-DB MCP smoke returned sanitized contact/group counts and MsgLib source refs without printing names, JIDs, message bodies, file paths, or raw rows.
32. C32: `isphere_receive_messages` can now use the optional MsgLib display-entity source to fill safe `sender_name` and `conversation.display_name` fields while preserving message text/content/attachment behavior.
33. C33: optional real copied-DB MCP smoke now also constructs a synthetic encrypted PacketReader fixture from internally selected MsgLib contact/group metadata and verifies receive-message display enrichment through MCP; output stays sanitized to counts, booleans, and `msglib:<source_table>` refs only.
34. C34: `docs/go-mcp-runbook.md` now documents the operator setup path for optional MsgLib display-name enrichment, including the required env vars, partial-config failure rule, standard no-MsgLib verification, optional provider smoke, optional MCP enrichment smoke, and sanitized output boundaries.
35. C35: `docs/source-discovery/2026-07-10-msglib-message-source-precheck.md` maps `tblPersonMsg`, `tblMsgGroupPersonMsg`, `TD_SystemMessageRecord`, receipt, recent, and file-record schema columns to the future `isphere_receive_messages` contract; decision is metadata-only `message_sources` sidecar first, not body-returning DB listing yet.
36. C36: added metadata-only MsgLib `message_sources` across the x86 sidecar, Go wrapper, and sidecar verification; evidence-backed smoke returned six message-source summaries with source/table/role/availability/row-count metadata only.
37. C37: `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md` defines the bounded DB-backed receive-message design: sidecar op `list_messages`, hard limit, no cursor initially, explicit `include_body`, no raw rows, no file path values, PacketReader remains default until reconciliation.
38. C38: added sidecar/Go-wrapper `list_messages`, clamped to `1..50`, cursor rejected, optional attachment metadata without path/download refs, and copied-DB verification with `include_body=false`; printed evidence is only count/source-table/safety booleans.
39. C39: added `internal/isphere.MsgLibMessageSource`, an injected-provider adapter that maps `msglib.ListMessages` results into the existing receive-message domain model, preserving explicit source refs and attachment metadata while leaving MCP default PacketReader behavior unchanged.
40. C40: added tool-level explicit receive source selection: empty/`auto`/`local_readonly` still use the primary PacketReader/log-backed source, while `source_preference="msglib_readonly"` uses a configured MsgLib receive source and is rejected when not configured.
41. C41: wired env-configured MsgLib client into both display enrichment and explicit receive selection; optional copied-DB MCP smoke now proves `source_preference="msglib_readonly"` returns sanitized count/source evidence while standard `verify-go-mcp.ps1` remains deterministic.
42. R5b/C30-live: analyzed returned live-probe package `isphere-live-probe-20260710-121958`; confirmed one x86 `IMPlatformClient.exe` `4.1.2.6842`, live server endpoint evidence, installed B-route DLL presence, active local `MsgLib.db` path shape, and A-route UIA selectors for search/draft/send/file menu. Production send remains blocked because the probe did not prove runtime entrypoint reachability or send success/ack mapping and the captured chat panels report an offline-send blocker.
43. R6a/C31: added WinHelper `0.4.0` read-only `probe_send_entrypoints` and `probe_send_uia_controls`, wired the live recorder to emit `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`, and verified that copied install metadata reports all 5 required B-route entrypoints available while the synthetic UIA classifier reports `A_ROUTE_OFFLINE_BLOCKED`. Production send/file upload remains blocked pending preview/dry-run, dynamic observation, idempotency/audit, and one approved sandbox send.
44. R6b: added `isphere_send_message` preview/dry-run only. The tool validates `target_type`, `target_id`, `content_sha256`, and `idempotency_key`, returns connector metadata `implatform-sidecar`/`preview`, appends redacted JSONL audit events without raw message body or raw idempotency key, and returns `send_status="blocked"` for production with all side-effect flags false. `verify-go-mcp.ps1` now verifies the 9-tool surface and proves `send_preview_tool_present=true` and `production_send_enabled=false`.
45. R6c: because the local machine cannot log in, prepared an online/internal sandbox evidence package instead of attempting local send. Added `scripts/package-send-sandbox-gate.ps1`, `scripts/verify-send-sandbox-gate-package.ps1`, and `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; local verification generates `runs/send-sandbox-gate-package.zip` with the read-only recorder, before/after scripts, sandbox input template, SHA256 helper, expected-return checklist, and return-zip helper.
46. R6c package consolidation: upgraded `runs/send-sandbox-gate-package.zip` into a one-pass operator recording suite with `RUN-RECORDING-SUITE.bat`, numbered `01/02/03` scripts, and `RECORDING-PACKAGE-MANIFEST.json`; added a second offline-safe `runs/send-capability-test-package.zip` with `isphere-capability-smoke.exe` plus packaged `ISphereWinHelper.exe` to verify the 9-tool MCP surface, receive/search/file-list fixture behavior, send preview, and `production_send_enabled=false`.
47. R6d partial intake: accepted `C:\Users\zhaoy\Downloads\1631.zip` as partial evidence only. Added `scripts\validate-returned-send-sandbox-package.ps1`, `scripts\test-validate-returned-send-sandbox-package.ps1`, and `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`. Validator reports `partial_evidence_usable=true` and `r6d_gate_pass=false`: capability test passed, content hash matched, idempotency key/success/no-second-send fields were present, but after-recorder output and manual send timestamps are missing. This can unblock sandbox-only connector-shell/idempotency work, not production send.
48. R6e: hardened `isphere_send_message` with a sandbox-only connector shell marker and idempotency store. Preview remains no-send; production remains blocked. Duplicate idempotency keys for the same target/content are detected and reported; conflicting reuse of the same idempotency key for different content/target is blocked with all side-effect flags false. `ISPHERE_SEND_IDEMPOTENCY_PATH` can point to a JSONL state file; standard verification uses per-run temporary state.
## Recompiled business roadmap
The active plan is now organized around the four business goals rather than the old technology-led C-loop:
1. Search contacts and search groups: harden result quality, ranking, de-duplication, and richer fields.
2. Receive/send messages: reconcile PacketReader and MsgLib receive sources first; then discover and validate a send-message connector.
3. Receive/send files: map received file metadata to cache/download behavior first; send-file remains behind the send connector plus upload evidence.
4. Keep UIA selector/report work as fallback support, not as the primary roadmap.
R1 receive-message source reconciliation precheck is complete:
- Created `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`.
- Decision: do not change default receive routing yet; empty/`auto` remains PacketReader/log-backed and `msglib_readonly` remains explicit.
- R1 next node was R2 contact/group search quality hardening.
R2 contact/group search quality hardening is complete:
- Search contacts now has deterministic exact-match-first ranking and case-insensitive de-duplication for log/JID candidates and optional MsgLib display enrichment.
- Search groups now has deterministic exact-match-first ranking and case-insensitive de-duplication for groupchat/conference candidates and optional MsgLib display enrichment.
- MCP output still preserves `source` and `raw_ref`; no send or download behavior was introduced.
- Next node: R3 receive-file download mapping precheck.
R3 receive-file download mapping precheck is complete:
- Created `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`.
- Decision: do not implement download mode yet; current evidence supports list mode and DB attachment metadata, but not a validated cache/download mapping.
- Next node: R3b file cache mapping diagnostic with sanitized counts only.
R3b file cache mapping diagnostic is complete:
- Added `scripts/verify-file-cache-mapping.ps1` and a fixture test script.
- Real sanitized diagnostic found 166 MsgLib received-file records and 11 cache/archive candidates, but 0 filename/source-id/cache matches.
- Decision: R4 receive-file download implementation is blocked until better cache evidence or a UI/client download connector is available.
- Next node: R5 send-message connector discovery.
R5 send-message connector discovery is complete:
- Created `docs/source-discovery/2026-07-10-send-message-connector-selection.md`.
- No connector was selected: no bridge/API/local service, no UIA write action chain, and no network/protocol send evidence is currently validated.
- R6 send-message implementation and R7/R8 send-file work are blocked until a connector evidence package is supplied.
R5b returned live-probe evidence intake is complete:
- Created `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`.
- Runtime evidence now proves a real x86 `IMPlatformClient.exe` process, version `4.1.2.6842`, install path family, and live TCP connection.
- The live install contains B-route candidate binaries including `IMPlatformClient.exe`, `smack.dll`, `IMPP.Interface.dll`, `IMPP.Service*.dll`, `IMPP.UI.dll`, `TcpFileTransfer.dll`, `INetwork.dll`, and `IOClientNetwork.dll`.
- The live process did not show `IMPP.*`, `smack.dll`, or file-transfer assemblies as currently loaded modules, so B2 runtime entrypoint reachability is still unproven.
- UIA evidence now locates main search edit `skinAlphaTxt`, chat send document `rtbSendMessage`, send button `btnSend`, and `发送文件` menu controls, but both chat captures include the offline-send message `您已处于离线状态,无法发送消息,请上线后再次尝试!`.
- Decision: move from generic “waiting for connector evidence” to R6a/C31 non-mutating send connector preflight. Keep `isphere_send_message` and `isphere_send_file` production behavior blocked.
R6a/C31 non-mutating send connector preflight is complete:
- Created `docs/source-discovery/2026-07-10-send-connector-preflight.md`.
- Added helper `probe_send_entrypoints`, which checks installed/copied `IMPlatformClient.exe`, `smack.dll`, and `IMPP.Service.dll` metadata only.
- Added helper `probe_send_uia_controls`, which classifies search/edit/send/file controls and offline-blocked state without clicking or typing.
- Local copied-install metadata reports `required_available_count=5`, `all_required_available=true`, and `b_route_entrypoint_preflight="pass"`.
- Synthetic UIA smoke reports `A_ROUTE_OFFLINE_BLOCKED`, proving the classifier can preserve the offline blocker as a first-class route state.
- Decision: next node is R6b B-route preview/dry-run contract. Keep production `isphere_send_message` and `isphere_send_file` blocked until a later online sandbox send proves success/ack and idempotency behavior.
R6b B-route preview/dry-run contract is complete:
- Added `internal/tools/isphere_send_message.go` and tests.
- Registered `isphere_send_message` in the Go MCP server as preview/dry-run only.
- Preview returns `ok=true`, `send_status="planned"`, target ref, content SHA256, idempotency key SHA256, connector metadata, audit metadata, and all side-effect flags false.
- Production returns `ok=false`, `send_status="blocked"`, a clear blocked reason, and all side-effect flags false.
- `ISPHERE_SEND_AUDIT_PATH` can override the local audit JSONL path; the default path is ignored under `runs/send-audit/send-message-preview.jsonl`.
- Standard MCP smoke now verifies 9 tools and confirms production send is not enabled.
- Decision: next node is R6c online sandbox-send evidence gate. Do not implement production send until one approved sandbox send proves success/ack or sent-record evidence and duplicate idempotency protection.
R6c local preparation for online sandbox-send evidence is complete:
- Local login is still unavailable, so no local live send was attempted.
- Created `docs/source-discovery/2026-07-10-send-sandbox-gate.md`.
- Added package script `scripts/package-send-sandbox-gate.ps1`.
- Added verification script `scripts/verify-send-sandbox-gate-package.ps1`.
- Verified package output: `runs/send-sandbox-gate-package.zip`.
- Package now includes an all-in-one `RUN-RECORDING-SUITE.bat`, numbered `01/02/03` scripts, and a manifest so the online/internal sandbox operator can execute the full recording/return flow from one extracted folder.
- Added `scripts/package-send-capability-test.ps1`, `scripts/verify-send-capability-test-package.ps1`, and `cmd/isphere-capability-smoke`.
- Verified second package output: `runs/send-capability-test-package.zip`.
- The capability-test package is offline-safe and confirms 9 MCP tools, fixture-backed receive/search/file-list behavior, send preview, no real send, and `production_send_enabled=false`.
- Production `isphere_send_message` and `isphere_send_file` remain blocked until the returned package proves success/ack or sent-record evidence and no second send.
- Next node: R6d returned sandbox evidence intake after the package is run and returned.
R6d partial returned sandbox evidence intake is complete:
- Local intake source: `C:\Users\zhaoy\Downloads\1631.zip`.
- Added repeatable validator: `scripts\validate-returned-send-sandbox-package.ps1`.
- Added fixture test: `scripts\test-validate-returned-send-sandbox-package.ps1`.
- Created `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`.
- Validator decision: `partial_use_only`.
- `partial_evidence_usable=true`: use it to continue sandbox-only send connector shell and idempotency/audit hardening.
- `r6d_gate_pass=false`: do not enable production `isphere_send_message`, because after-recorder output and manual send timestamps are missing.
- Next node: R6e sandbox-only send connector shell and idempotency audit hardening.
R6e sandbox-only send connector shell and idempotency audit hardening is complete:
- Added `SendMessageIdempotencyStore` and file-backed `ISPHERE_SEND_IDEMPOTENCY_PATH`.
- `isphere_send_message` response now exposes `connector_mode="sandbox-only-shell"` and `production_send_enabled=false`.
- Duplicate same-key/same-target/same-content preview calls return structured metadata with `duplicate_detected=true` and no side effects.
- Same idempotency key reused for different target/content returns `send_status="blocked"` with `conflict_detected=true` and no side effects.
- `scripts\verify-go-mcp.ps1` and `cmd\isphere-capability-smoke` use fresh per-run idempotency state to keep repeatable smoke deterministic.
- R6f started after user approval of the continuous execution plan.
R6f fake/sandbox send connector contract is complete:
- Added `internal/tools/send_message_connector.go` with `SendMessageConnector`, `SendMessageConnectorRequest`, and `SendMessageConnectorResult`.
- Added injected test-only `RegisterISphereSendMessageToolWithStateAndConnector`.
- Added fake connector accepted/failed tests proving connector outcome mapping, ack/error fields, redacted audit, and no real side effects.
- Default MCP server still registers no real connector; `execution_mode="production"` remains blocked and `production_send_enabled=false`.
- Next node: R6g send audit and idempotency replay diagnostics.
Continuous execution plan R6f-R14 is approved and execution has started:
- Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
- Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report.
- Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action.
- Last completed round: R14 release-candidate report.
- Next active round: wait for returned online evidence or start a new evidence-driven follow-up loop.
R6g send audit and idempotency replay diagnostics is complete:
- Added `scripts\verify-send-audit-idempotency.ps1`.
- Added `scripts\test-verify-send-audit-idempotency.ps1`; the first run failed before the verifier existed, then passed after implementation.
- Diagnostic output reports sanitized `audit_records`, `idempotency_records`, `duplicate_detected`, `conflict_detected`, `raw_body_present`, and `raw_idempotency_key_present`.
- Added `TestISphereSendMessageAuditRedactionIsStable` proving file-backed audit JSONL does not store raw message body or raw idempotency key.
- Standard verification still reports 9 tools and `production_send_enabled=false`.
- Next node: R6h central production gate policy for send message.
R6h central production gate policy for send message is complete:
- Added `SendMessageGatePolicy` and `LoadSendMessageGatePolicy`.
- Gate reason codes are now explicit: `send_disabled_by_default`, `send_evidence_missing`, `send_connector_missing`, and `send_gate_passed`.
- Default production-blocked `isphere_send_message` responses and audit events include `gate_reason_code` and `gate_reason_message`.
- `ISPHERE_SEND_PRODUCTION_ENABLED=1` is recognized by policy tests, but it is not enough by itself; evidence and connector gates must also pass.
- Standard verification still reports 9 tools and `production_send_enabled=false`.
- Next node: R6i B-route connector adapter shell.
R6i B-route connector adapter shell is complete:
- Added `BRouteSendAdapterConfig` and `NewBRouteSendMessageConnector`.
- `disabled` mode returns `broute_mode_blocked`.
- `dry_run_contract` validates request shape and returns `broute_dry_run_only`.
- The shell does not start sidecars, load DLLs, click/type UI, replay network traffic, or upload files.
- Standard verification still reports 9 tools and `production_send_enabled=false`.
- Next node: R6j online sandbox-send recording package v2.
R6j online sandbox-send recording package v2 is complete:
- `scripts\verify-send-sandbox-gate-package.ps1` now requires strict-v2 files and field markers.
- `scripts\package-send-sandbox-gate.ps1` now emits `RETURN-CHECKLIST-V2.md`, `SANDBOX-SEND-INPUTS.schema.json`, `CREATE-RETURN-ZIP.ps1`, and strict-v2 fields in the input template.
- `CREATE-RETURN-ZIP.ps1` refuses to create a return zip if after-recorder output or strict-v2 operator fields are missing.
- `scripts\validate-returned-send-sandbox-package.ps1` supports `-ZipPath` and `-StrictV2`.
- `scripts\test-validate-returned-send-sandbox-package.ps1` proves the existing partial package remains `partial_use_only` and fails strict-v2.
- Standard verification still reports 9 tools and `production_send_enabled=false`.
- Next node: R6k returned sandbox evidence intake v2.
R6k returned sandbox evidence intake v2 is complete:
- Scanned `C:\Users\zhaoy\Downloads`; no new strict-v2 returned send package was found.
- Re-validated `C:\Users\zhaoy\Downloads\1631.zip` with `-ZipPath` and `-StrictV2`; strict-v2 failed as expected.
- Created `docs\source-discovery\2026-07-10-returned-send-sandbox-analysis-v2.md`.
- Decision: `r6d_gate_pass=false`, `strict_v2_pass=false`, production `isphere_send_message` remains blocked.
- Standard verification still reports 9 tools and `production_send_enabled=false`.
- Next node: R6l send-message production path gate closure.
R6l send-message production path gate closure is complete:
- Because R6k found `strict_v2_pass=false`, production `isphere_send_message` remains blocked.
- Added regression coverage for `ISPHERE_SEND_PRODUCTION_ENABLED=1` with missing strict evidence; response returns `gate_reason_code="send_evidence_missing"`.
- Added an explicit skipped gated-production test with reason `strict v2 send evidence not present in this repository state`.
- Standard verification still reports 9 tools and `production_send_enabled=false`.
- Next node: R7 send-file preview tool.
R7 send-file preview tool is complete:
- Added `isphere_send_file` as the tenth MCP tool.
- Preview validates target, idempotency key, `ISPHERE_SEND_FILE_ALLOWED_DIR`, file existence, file SHA256, and file size.
- Preview returns `send_status="planned"`, `real_send_attempted=false`, `production_send_enabled=false`, `file_sha256`, `file_size_bytes`, and all upload/send side-effect flags false.
- `execution_mode="production"` returns structured blocked metadata; no upload, click, type, hook, network capture, or client mutation is introduced.
- Verification passed: focused send-file tests, 10-tool MCP registration test, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`.
- Next node: R8 send-file idempotency and audit hardening.
R8 send-file idempotency and audit hardening is complete:
- Added `ISPHERE_SEND_FILE_AUDIT_PATH` and `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH` support.
- Duplicate same target/file/idempotency key returns `duplicate_detected=true` and remains no-send.
- Reusing the same idempotency key for a different target or file hash returns structured blocked metadata with `conflict_detected=true`.
- Send-file audit/idempotency JSONL stores hashes, target refs, size, result, and side-effect flags; it does not store raw file content, raw local file path, or raw idempotency key.
- Production file upload remains blocked with `production_send_enabled=false` and `real_send_attempted=false`.
- Verification passed: focused send-file tests, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`.
- Next node: R9 send-file upload evidence package.
R9 send-file upload evidence package is complete:
- Added `scripts\package-send-file-sandbox-gate.ps1` to build `runs\send-file-sandbox-gate-package.zip`.
- Added `scripts\verify-send-file-sandbox-gate-package.ps1` to assert package contents and required operator markers.
- Added `scripts\validate-returned-send-file-sandbox-package.ps1` to validate returned online/internal sandbox file-send evidence.
- Added `docs\source-discovery\2026-07-10-send-file-sandbox-gate.md`.
- Package includes read-only live probe recorder, `capability\isphere-capability-smoke.exe`, `FILE-SEND-INPUTS.template.json`, `CREATE-FILE-RETURN-ZIP.ps1`, and `RUN-FILE-RECORDING-SUITE.bat`.
- Local verification generated and validated `runs\send-file-sandbox-gate-package.zip`; no production file upload connector was enabled.
- Verification passed: git diff --check, scripts\verify-send-file-sandbox-gate-package.ps1, go test ./..., go build ./cmd/isphere-mcp, and scripts\verify-go-mcp.ps1.
- Next node: R10 receive-file download resolver v2 diagnostic.
R10 receive-file download resolver v2 diagnostic is complete:
- Added fixture-backed scoring to `scripts\verify-file-cache-mapping.ps1` with `-FixtureRecordsPath`, `-CacheRoot`, and `-SanitizedOutputPath`.
- `scripts\test-verify-file-cache-mapping.ps1` now covers the legacy archive-list summary and the v2 scoring fixture.
- Fixture result: `records_checked=3`, `cache_candidates_checked=4`, `accepted_matches=2`, `ambiguous_matches=0`, `score_100_matches=1`, `score_80_matches=1`, and `score_40_only_matches=1`.
- Diagnostic output keeps `raw_paths_printed=false`, `file_contents_read=false`, and `file_paths_returned=false`.
- Decision: resolver logic is proven, but real production file download remains blocked until a real cache mapping package produces accepted non-ambiguous matches.
- Next node: R10a returned send-sent record diagnostic intake, because two new operator packages arrived.
R10a returned send-sent record diagnostic intake is complete:
- Added `scripts\validate-send-sent-record-diagnostic-package.ps1` and `scripts\test-validate-send-sent-record-diagnostic-package.ps1`.
- Reviewed `send-sent-record-diagnostic-package1(1).zip` and `send-sent-record-diagnostic-package2(1).zip` from Downloads using safe summary output only.
- Both packages report `operator_observed_success=true`, `do_not_send_second_time_confirmed=true`, and `content_sha256_matches_input=true`.
- Both packages also report `exact_content_seen_after=false`, `content_sha256_seen_after=false`, `after_offline_blocked=true`, `strict_send_record_pass=false`, and `production_send_unlock_recommended=false`.
- Decision: production `isphere_send_message` remains blocked; the packages are useful manual evidence but not enough to open the send gate.
- Next node: R11 receive-file download preview contract.
R11 receive-file download preview contract is complete:
- Added `file_ref` to `isphere_receive_files` download args and introduced a download resolver boundary for tests/future mapping.
- `mode="download"` with `preview=true` now returns structured content instead of a generic tool error.
- Without an accepted mapping, response is `ok=false`, `download_status="blocked"`, and `blocked_reason_code="file_cache_mapping_missing"`.
- With an injected fixture mapping, response is `ok=true`, `download_status="planned"`, `mapping_source`, and `matched_score`.
- Both paths keep `file_contents_read=false`, `file_copied=false`, `real_download_attempted=false`, and `raw_paths_returned=false`; real file copy remains blocked.
- `scripts\verify-go-mcp.ps1` now asserts `receive_file_download_preview_blocked=true`.
- Next node: R12 receive-message reconciliation helper.
R12 receive-message reconciliation helper is complete:
- Added `internal/isphere/message_reconciliation.go` and `internal/isphere/message_reconciliation_test.go`.
- Strong match uses exact non-empty `message_id`; medium match uses same `conversation_id`, same `sender_id`, and timestamp within 3000 ms.
- Auto merge is recommended only when all records are strong matches with no unmatched or medium-only rows.
- Added `scripts\verify-receive-source-reconciliation.ps1 -UseFixture`, which outputs sanitized counts/booleans only.
- Fixture output: `strong_matches=1`, `medium_matches=1`, `unmatched_left=1`, `unmatched_right=1`, `auto_merge_recommended=false`, and `exact_fixture_auto_merge_recommended=true`.
- Default `isphere_receive_messages` routing is unchanged; `msglib_readonly` remains explicit only.
- Next node: R13 business goals smoke.
R13 business goals smoke is complete:
- Added `scripts\verify-business-goals-smoke.ps1`.
- Added `docs\reports\2026-07-10-business-goals-smoke.md`.
- Smoke result: contacts/search groups/receive messages/file list/send preview/send-file preview are ready.
- Smoke result: production send, real file download, and production file upload remain blocked.
- Output blockers: `send_message_production_missing_strict_machine_sent_record_evidence`, `receive_files_download_missing_real_cache_mapping_and_copy_gate`, and `send_file_production_missing_upload_connector_and_returned_file_send_evidence`.
- Next node: R14 release-candidate report.
R14 release-candidate report is complete:
- Rebuilt and verified `runs\send-sandbox-gate-package.zip`.
- Rebuilt and verified `runs\send-file-sandbox-gate-package.zip`.
- Added `docs\reports\2026-07-10-r6f-r14-release-candidate.md`.
- Updated `docs\source-discovery\capability-source-matrix.md` with R10a/R11/R12/R13/R14 decisions.
- Updated `docs\go-mcp-runbook.md` with the R14 business status and package paths.
- Verification passed: package builders/verifiers, `git diff --check`, `scripts\verify-business-goals-smoke.ps1`, `go test ./...`, `go build ./cmd/isphere-mcp`, and `scripts\verify-go-mcp.ps1`.
- Final R14 business conclusion: search contacts, search groups, receive messages, and receive-file list are usable; send-message preview and send-file preview are usable; production send, real file download, and production file upload remain evidence-blocked.
## A-route UIA send branch
A-route UIA send work has been merged into `main`:
- Route positioning: A-route/RPA is a fallback, not the primary product route. B-route sidecar/API connector evidence remains the preferred path for real send/file capability.
- User direction: do not add a product approval gate; implement function first and let the digital employee layer decide when to call it.
- Added plan `docs\superpowers\plans\2026-07-11-a-route-rpa-send.md`.
- Go connector request now carries raw `ContentText` only to the action connector; response and audit remain redacted.
- Added Go `uia-rpa` send connector adapter and env loader.
- Added C# helper op `uia_send_message` that sets the send editor text and invokes the send button through UI Automation.
- Synthetic local verification in `scripts\verify-win-helper.ps1` proves the helper can write to a WinForms send box and trigger a send button marker.
- Added `scripts\open-offline-real-frmP2PChat.ps1`, which opens a real offline/unlogged `frmP2PChat` and confirms `rtbSendMessage` / `btnSend` selectors.
- Visual real-window demo proved the local UI can accept text and receive a send-button click, but the client remains offline and reports that the message cannot be sent.
- Default MCP smoke still clears A-route env and remains preview/blocked unless explicitly configured.
2026-07-12 business correction:
- `isphere_send_message` now has two different readiness levels:
- preview/dry-run: stable MCP contract, no send.
- A-route UI action: configured UI write/click works against synthetic and real offline windows.
- It does **not** yet have verified online business delivery. Do not describe `clicked_ui=true` or helper `sent_message=true` as “消息已送达”.
- `isphere_send_file` remains preview-only for production file upload. The real offline UIA dump did not expose `btnSendFile` as a stable automation id, so file upload needs a separate toolbar/menu locator node.

View File

@@ -6,7 +6,7 @@ Make iSphere messages usable by AI digital employees without rebuilding the full
## First POC
The first POC reads recent authorized messages, converts them into a normalized JSON model, generates draft replies, and records evidence. It does not automatically send messages.
The first POC reads recent messages, converts them into a normalized JSON model, generates draft replies, and records evidence. Send/file capabilities are part of the staged MCP tool roadmap.
## Architecture
@@ -46,12 +46,11 @@ iSphere / MockCloud / existing bridge
}
```
## Approval Policy
## Write Execution Model
- Read access can be automated in test scope.
- Draft generation can be automated.
- Sending messages requires human approval in the first implementation.
- Every send action must log target, content hash, timestamp, approver, and connector.
- Read access and draft generation can be automated in test scope.
- Send/file tools are staged after source discovery and connector selection.
- Every write action records target, content or file hash, timestamp, operator, and connector.
## Verification

View File

@@ -11,25 +11,14 @@ MCP Client
-> 已登录的 iSphere / IMPlatformClient 窗口
```
当前阶段提前固定 Go 与 C# 之间的边界,不实现 Go MCP 主程序,也不实现真实搜索、发送、文件操作。
当前阶段提前固定 Go 与 C# 之间的边界Go MCP 主程序真实搜索、发送、文件操作进入后续节点
第一阶段的实际目标是:用 C# helper 只读探测已登录客户端窗口,并把窗口和 UI Automation 控件树以稳定 JSON 返回给未来 Go MCP 服务。
第一阶段的实际目标是:用 C# helper 探测已登录客户端窗口结构,并把窗口和 UI Automation 控件树以稳定 JSON 返回给未来 Go MCP 服务。
## 2. 不做什么
## 2. 当前阶段范围
当前阶段不做以下内容:
当前阶段只定义 Go 与 C# helper 的分工Go 负责 MCP 服务层C# helper 负责 Windows 桌面/UIA 执行层。真实联系人、群组、消息、文件能力进入 `docs/mcp-core-tools-contract.md` 的核心工具路线。
- 不写 Go MCP 实现代码。
- 不做 C# 常驻后台进程。
- 不做 Windows Service。
- 不做 named pipe、gRPC、HTTP 本地服务。
- 不做插件框架。
- 不做 selector 自动学习系统。
- 不执行搜索联系人、发消息、发文件、收文件。
- 不自动登录。
- 不读取密码、token、cookie、私钥。
- 不注入进程、不 hook、不读目标进程内存、不提权。
- 不加壳、不混淆、不伪装系统进程。
## 3. 分工原则
@@ -42,11 +31,11 @@ Go 是对外服务层,负责:
- 调用 C# helper。
- 控制超时。
- 解析 helper JSON 输出。
- 审批校验
- 审计记录。
- 工具执行策略
- 执行审计记录。
- 把 helper 结果整理成 MCP 返回值。
Go 不直接操作窗口,不直接使用 UIA不点击按钮不保存 iSphere 控件细节
Go 通过 helper 获取窗口结构和执行结果iSphere 控件细节由 C# helper 封装
### 3.2 C# Windows Helper
@@ -57,7 +46,7 @@ C# 是 Windows 执行层,负责:
- 读取指定窗口的 UI Automation 控件树。
- 后续在 selector 稳定后执行少量受控窗口动作。
C# 不做 MCP不做审批策略中心不做业务状态中心不联网不常驻
C# 保持 Windows helper 定位,只返回 Go 请求的 helper 结果
## 4. 进程模型
@@ -75,9 +64,9 @@ C# 退出
- 简单。
- 易测试。
- 不常驻后台,减少安防误报和运维复杂度
- 不把联系人、消息、文件路径暴露在命令行参数
- 后续如果确实需要提速,再评估长连接,不提前设计
- 默认一次一进程,便于调试和替换
- 联系人、消息、文件路径通过 stdin JSON 传入,命令行参数保持简单
- 后续需要提速,再评估长连接。
标准约束:
@@ -165,13 +154,13 @@ UIA_DUMP_FAILED
SELECTOR_NOT_FOUND
APP_NOT_LOGGED_IN
APP_BUSY
APPROVAL_REQUIRED
WRITE_NOT_READY
VERIFY_FAILED
```
## 6. 第一阶段 op
## 6. 当前只读 helper op
第一阶段只支持四个 op
当前 helper 只支持只读探测类 op。`version``self_check``scan_windows``dump_uia` 是第一阶段窗口/UIA 基线;`probe_client_runtime` 是 C30 为 B 路线发送连接器新增的只读运行时探测;`probe_send_entrypoints``probe_send_uia_controls` 是 R6a/C31 为发送连接器新增的只读预检
### 6.1 `version`
@@ -193,7 +182,7 @@ VERIFY_FAILED
```json
{
"helper_name": "ISphereWinHelper",
"helper_version": "0.1.0",
"helper_version": "0.4.0",
"protocol": "isphere.helper.v1",
"runtime": ".NET Framework"
}
@@ -283,7 +272,181 @@ args 示例:
runs/real-loggedin-lab/uia-dumps/
```
C# helper 默认不主动写文件,除非未来明确加入 `out_path` 参数。
C# helper 文件输出通过未来 `out_path` 参数控制
### 6.5 `probe_client_runtime`
用途:为 B 路线发送连接器做只读运行时探测,确认本机是否存在已运行的 `IMPlatformClient.exe` / iSphere 相关进程,以及进程位数、安装路径、关键模块是否加载。
这个 op 只做进程和模块枚举:
- 不发送消息;
- 不上传文件;
- 不点击、不输入;
- 不注入、不 hook
- 不读取聊天内容。
请求示例:
```json
{
"protocol": "isphere.helper.v1",
"request_id": "...",
"op": "probe_client_runtime",
"timeout_ms": 5000,
"args": {
"process_names": ["IMPlatformClient", "IMPP.ISphere", "iSphere", "importal"],
"include_modules": true,
"max_modules": 120
}
}
```
响应 data 示例:
```json
{
"probe_mode": "read_only_process_module_inventory",
"host": {
"is_64_bit_os": true,
"is_64_bit_process": true
},
"target_count": 1,
"targets": [
{
"pid": 1234,
"process_name": "IMPlatformClient",
"main_window_title": "iSphere",
"executable_path": "C:\\...\\IMPlatformClient.exe",
"bitness": "x86",
"assembly_presence": {
"IMPlatformClient.exe": true,
"smack.dll": true,
"IMPP.Interface.dll": true,
"IMPP.ServiceBase.dll": true,
"IMPP.Service.dll": true
},
"module_probe_ok": true
}
]
}
```
该 op 是 C30 的 B 路线入口检查。它暂不等于发送能力,只用于判断后续是否值得做 sidecar / in-process 调用验证。
### 6.6 `probe_send_entrypoints`
用途:对复制或安装目录里的 iSphere / IMPlatformClient 二进制做只读元数据检查,确认 B 路线是否存在文本发送和文件发送候选入口。
这个 op 只做文件存在、版本、SHA256、.NET 反射/字符串元数据检查:
- 不发送消息;
- 不上传文件;
- 不点击、不输入;
- 不注入、不 hook
- 不抓包;
- 不读取聊天内容。
请求示例:
```json
{
"protocol": "isphere.helper.v1",
"request_id": "...",
"op": "probe_send_entrypoints",
"timeout_ms": 15000,
"args": {
"install_dir": "C:\\Program Files (x86)\\Impp"
}
}
```
响应 data 示例:
```json
{
"probe_mode": "read_only_send_entrypoint_metadata",
"summary": {
"entrypoint_count": 7,
"available_count": 7,
"required_count": 5,
"required_available_count": 5,
"all_required_available": true,
"b_route_entrypoint_preflight": "pass"
},
"entrypoints": [
{
"id": "text_high_level_by_jid",
"assembly": "IMPlatformClient.exe",
"type_name": "IMPP.Client.Core.Plugins.Manager.AppContextManager",
"method_name": "SendTxtMessageByJid",
"available": true
}
],
"safety": {
"sent_message": false,
"sent_file": false,
"uploaded_file": false,
"clicked_ui": false,
"typed_text": false,
"captured_network": false,
"attached_hook": false,
"modified_client_data": false
}
}
```
该 op 是 R6a/C31 的 B 路线入口可达性预检。它证明“值得继续做 preview/dry-run”但不等于生产发送能力。
### 6.7 `probe_send_uia_controls`
用途:对指定窗口做只读 UIA 分类,判断 A 路线 fallback 所需的搜索框、接收区、发送编辑区、发送按钮、发送文件菜单和离线发送阻断状态是否可识别。
请求示例:
```json
{
"protocol": "isphere.helper.v1",
"request_id": "...",
"op": "probe_send_uia_controls",
"timeout_ms": 5000,
"args": {
"hwnd": "0x001A0B2C",
"max_depth": 8,
"max_children": 200
}
}
```
响应 data 示例:
```json
{
"probe_mode": "read_only_uia_send_control_classifier",
"flags": {
"has_search_edit": true,
"has_receive_document": true,
"has_send_editor": true,
"has_send_button": true,
"has_file_menu": true,
"offline_blocker_visible": true,
"send_button_enabled": false,
"route_hint": "A_ROUTE_OFFLINE_BLOCKED"
},
"safety": {
"sent_message": false,
"sent_file": false,
"uploaded_file": false,
"clicked_ui": false,
"typed_text": false,
"captured_network": false,
"attached_hook": false,
"modified_client_data": false
}
}
```
该 op 不返回聊天内容。控件名只返回固定 safe label 或是否存在,用于判断后续是否能进入 A 路线 draft-only 预检。
## 7. 后续 op 扩展顺序
@@ -296,67 +459,28 @@ search_contacts
open_conversation
read_latest_messages
write_draft
send_after_approval
send_file_after_approval
receive_file_after_approval
send_message
send_file
receive_file
```
其中:
- `search_contacts``open_conversation``read_latest_messages` 可先做只读或低风险动作
- `write_draft` 只能写入草稿,不点击发送
- `send_after_approval` 必须带 `approval_id`
- 文件发送、文件接收也必须带 `approval_id`
- `search_contacts``open_conversation``read_latest_messages` 是后续核心工具候选 op
- `write_draft` 用于构造待发送内容
- `send_message``send_file``receive_file` 归入核心 MCP 工具路线
## 8. 审批和审计边界
## 8. 写入与审计接口
审批主逻辑放在 Go
Go 负责把业务工具请求转换成 helper op并记录目标、内容摘要、文件摘要、时间、连接器和执行结果。C# helper 只执行 Go 发来的单次 op并返回可核验结果
Go 负责:
## 9. 部署形态
- 生成审批记录
- 校验 `approval_id`
- 记录操作者、目标联系人、消息摘要、文件路径摘要、时间、结果。
C# 负责二次保护:
- 对真实发送和文件类 op如果没有 `approval_id`,直接返回 `APPROVAL_REQUIRED`
- 执行前确认当前窗口和目标会话仍然匹配。
- 执行后返回可核验结果。
第一阶段没有真实发送类 op因此不实现审批逻辑只在协议里预留字段。
## 9. 安防低误报约束
helper 必须保持普通企业辅助工具形态:
- 普通命令行程序。
- 不需要管理员权限。
- 不注入目标进程。
- 不 hook 全局键盘鼠标。
- 不读目标进程内存。
- 不修改目标程序文件。
- 不自启动。
- 不隐藏进程。
- 不联网。
- 不加壳、不混淆。
- 不伪装系统进程名。
- 默认不写日志文件。
- 只在 Go 指定输出路径时保存证据文件。
正式部署时依靠:
- 明确程序名。
- 固定安装路径。
- 代码签名。
- 企业白名单。
- 可审计日志。
不依靠规避、隐藏或绕过安防。
helper 保持普通企业辅助工具形态:普通命令行程序、普通安装路径、固定名称、可构建、可签名、可替换。正式部署关注安装路径、版本、日志和运维可见性
## 10. 建议目录结构
当前只需要规划,不需要一次性全部创建。
当前以规划为主,目录按节点逐步创建。
Go 侧未来结构:
@@ -375,8 +499,8 @@ go/
contract.go
audit/
audit.go
approval/
approval.go
execution/
execution.go
```
C# 侧第一阶段结构:
@@ -390,11 +514,11 @@ native/
UiaDumper.cs
```
第一阶段不要再拆更多目录。
第一阶段目录保持最小
## 11. 验收标准
第一阶段完成后,只验收:
当前只读 helper 基线验收:
1. C# helper 能编译。
2. `version` 返回合法 JSON。
@@ -403,17 +527,20 @@ native/
5. 打开记事本后,`dump_uia` 能导出记事本控件树。
6. 用户手动登录 iSphere 后,`scan_windows` 能找到候选窗口。
7. 对 iSphere 候选窗口执行 `dump_uia` 能返回控件树。
8. 全流程不点击、不输入、不发送、不修改目标客户端
8. `probe_client_runtime` 在无目标客户端运行时也能返回合法空结果
9. 用户手动登录 iSphere 后,`probe_client_runtime` 能返回目标进程、位数、路径和关键模块探测结果。
10. 当前验收仍聚焦只读探测;发送、文件上传、点击、输入都不在本节验收范围内。
## 12. 下一步
建议下一步只做 C# helper 第一阶段
当前下一步是 C30 B 路线运行时探测
```text
version
self_check
scan_windows
dump_uia
probe_client_runtime
```
Go MCP 代码暂不写。等 C# helper 能稳定拿到真实 iSphere 窗口结构后,再实现 Go 的 `helperclient` 和 MCP tools
Go MCP 代码已经具备基础 helper 调用能力;`probe_client_runtime` 暂不暴露为 MCP 业务工具。待真实登录客户端上的 B1/B2 探测通过后,再决定是否新增 sidecar 调用层和受控发送工具

View File

@@ -1,8 +1,8 @@
# Go MCP Minimal Plan Nodes
> **Process rule:** This is the single active plan for the next phase. The final MCP direction is Go. Python MCP, Python tests, offline-lab/native-lab Python layers are not part of the active path.
> **Process rule:** This is the single active plan for the next phase. The final MCP direction is Go. Old Python MCP, Python tests, offline-lab/native-lab Python layers are archival context outside the active path.
**Goal:** Build a minimal Go MCP server that exposes the existing C# `ISphereWinHelper.exe` read-only operations as MCP tools.
**Goal:** Build a minimal Go MCP server that exposes the existing C# `ISphereWinHelper.exe` observation operations as MCP tools.
**Architecture:** Go is the MCP/service layer. C# is the Windows/UI Automation execution layer. Go calls C# through one-shot stdin/stdout JSON using `isphere.helper.v1`.
@@ -33,9 +33,9 @@ Blocked/non-active:
-> N12-current Current-environment real logged-in UIA capture gate
```
**Reality update 2026-07-08:** iSphere is internal-network only. The current repository environment is an outer-network coordination/analysis environment. Therefore `N12-current` is not the active route unless network placement changes. Live login and live UIA capture must happen in an internal-network test sandbox through `N12R`.
**Reality update 2026-07-08:** iSphere is internal-network only. The current repository environment is an outer-network coordination/analysis environment. Therefore `N12-current` stays secondary unless network placement changes. Live login and live UIA capture must happen in an internal-network test sandbox through `N12R`.
Hard rule: **no node may start until the previous node's acceptance conditions are met.**
Node order: start each node after the previous node's acceptance conditions are met.
---
@@ -43,7 +43,7 @@ Hard rule: **no node may start until the previous node's acceptance conditions a
## N0: Baseline cleanup and direction lock
**Purpose:** Ensure the project is no longer split between Python MCP and Go MCP.
**Purpose:** Align the project on the Go MCP route.
**Preconditions:**
@@ -53,13 +53,13 @@ Hard rule: **no node may start until the previous node's acceptance conditions a
**Actions:**
- Confirm tracked files only include current docs/prompts/C# helper/scripts/evidence placeholders.
- Confirm no active `src/isphere_ai_bridge`, Python MCP package, or Python test suite is tracked.
- Confirm the tracked tree is aligned with the current Go/C# route.
- Confirm `docs/go-csharp-helper-boundary.md` remains the source boundary document.
**Expected outputs:**
- Clean project direction: C# helper now, Go MCP next.
- No Python MCP fallback path.
- Single active fallback path: Go MCP plus C# helper.
**Acceptance conditions:**
@@ -69,7 +69,7 @@ git status --short
Pass if:
- No unexpected modified or untracked files exist except the active plan file while editing.
- Working tree only contains expected files for the active edit.
- `git ls-files` includes `native/ISphereWinHelper/*`.
- `git ls-files` does not include `src/isphere_ai_bridge/*`.
- `git ls-files` does not include `tests/test_*.py`.
@@ -157,12 +157,12 @@ Initial module name:
isphere-ai-bridge
```
Do not create server code yet.
Create only the Go module at this node; server code starts in the later server node.
**Expected outputs:**
- A Go module exists.
- No dependencies are added unless required by standard Go tooling.
- Dependencies stay limited to standard Go tooling needs.
**Acceptance conditions:**
@@ -177,7 +177,7 @@ Pass if:
- `go version` exits 0.
- `go list ./...` exits 0 or reports only the root module with no packages before packages are created.
- No Python files are added.
- Generated files stay in the Go route.
**Stop conditions:**
@@ -369,14 +369,14 @@ Pass if:
- PowerShell verification exits 0.
- Go tests exit 0.
- Test output includes a real `version` call.
- No tests click/type/send/upload/download.
- Tests cover helper version/self-check/scan/dump paths only.
**Stop conditions:**
- Tests only pass with mocked helper and never call the real C# helper.
- C# helper passes alone but Go client cannot call it.
**Commit boundary:** no separate commit unless tests/scripts changed.
**Commit boundary:** separate commit only when tests/scripts changed.
**Next node:** N6.
@@ -395,7 +395,7 @@ Pass if:
Evaluate one current Go MCP library or official SDK option.
Record the decision inside the implementation commit or a short docs section. Do not create a large architecture document.
Record the decision inside the implementation commit or a short docs section.
Decision must answer:
@@ -411,7 +411,7 @@ why this is enough for four tools
**Expected outputs:**
- One MCP server package is selected.
- No competing MCP framework remains in the codebase.
- One MCP framework remains in the codebase.
**Acceptance conditions:**
@@ -464,7 +464,7 @@ Optional only if it keeps `main.go` small:
internal/mcpserver/server.go
```
Do not register real tools yet if the library supports a clean empty server test. If the library requires tools immediately, register only placeholder-free real tool definitions from N8.
Register the tool definitions needed for the current test surface; if the library supports an empty server test, keep tool registration for N8.
**Expected outputs:**
@@ -483,8 +483,8 @@ go build ./cmd/isphere-mcp
Pass if:
- Build exits 0.
- No Python runtime is required.
- No C# helper is launched during simple server construction tests.
- Server construction uses the Go runtime only.
- Simple server construction tests stay on the server construction path.
**Stop conditions:**
@@ -538,7 +538,7 @@ win_helper_dump_uia -> helper op dump_uia
- Four tool definitions exist.
- Tool handlers call `internal/helperclient`.
- No handler performs UI logic directly.
- Handlers delegate UI work through `internal/helperclient`.
**Acceptance conditions:**
@@ -556,13 +556,13 @@ Pass if tests prove:
- Tool schemas include only needed parameters:
- `include_all_visible` for scan.
- `hwnd`, `max_depth`, `include_text`, `max_children` for dump.
- No send/search/file tools exist.
- This phase contains only WinHelper observation tools.
**Stop conditions:**
- A fifth real action tool is added.
- A handler clicks, types, sends, uploads, downloads, or edits files.
- Tool code bypasses `internal/helperclient`.
- Tool code calls helper outside `internal/helperclient`.
**Commit boundary:**
@@ -622,8 +622,8 @@ And confirms:
- Four tool names are present.
- `win_helper_version` returns `ISphereWinHelper`.
- No real iSphere login is required.
- No message/file/search action is present.
- Verification uses synthetic/local helper checks.
- Message/file/search business actions are handled by the core MCP contract route.
**Stop conditions:**
@@ -690,23 +690,20 @@ go build ./cmd/isphere-mcp
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
```
And includes these boundaries:
And describes the current tool surface:
```text
no login automation
no send message
no send file
no receive/download file
no process injection
no hook
no memory reading
win_helper_version
win_helper_self_check
win_helper_scan_windows
win_helper_dump_uia
```
**Stop conditions:**
- Runbook says Python MCP is required.
- Runbook implies sending is implemented.
- Runbook omits approval boundaries for future sends.
- Runbook drifts back to the Python MCP route.
- Runbook states that send/file business tools are already implemented by the WinHelper observation phase.
- Runbook omits the route to `docs/mcp-core-tools-contract.md` for business send/file tools.
**Commit boundary:**
@@ -750,8 +747,8 @@ Pass if:
- `git status --short` has no output.
- Latest commits are small and tied to N3/N4/N8/N9/N10.
- No Python MCP files exist.
- No search/send/file tools exist.
- Go/C# route files are the active implementation surface.
- Business search/send/file tools remain in the core contract until implemented.
**Stop conditions:**
@@ -816,24 +813,24 @@ dump_uia
**Expected outputs:**
- Full sandbox artifact bundle for read-only offline inventory, or optional minimal UIA JSON package for advanced users.
- No claim that the current environment has a logged-in iSphere window.
- No claim that N12 passed.
- Package is labeled as offline/pre-gate evidence.
- N12 pass status remains tied to the real live-capture gate.
**Acceptance conditions:**
Pass if:
- In mode A, the copied bundle preserves directory structure and includes at least one useful `raw/` source area plus `copy-notes.txt` when available.
- In mode A, repository-side handling is read-only inventory: do not directly run unknown binaries and do not modify original copied files.
- In mode B, required JSON files exist and parse as JSON, and the manifest records offline pre-evidence only, not N12 pass.
- For both modes, the route does not design or perform automatic login, send message, send file, receive file, injection, hook, memory reading, or endpoint-security bypass.
- In mode A, repository-side handling is inventory-focused: preserve original copied files and inspect copies/metadata first.
- In mode B, required JSON files exist and parse as JSON, and the manifest records offline pre-evidence status.
- For both modes, the route stays focused on evidence intake and source discovery for the core MCP tools.
**Stop conditions:**
- Any step requires the user to understand JSON before they can provide the recommended full bundle.
- Any step directly runs unknown binaries from the copied bundle.
- Any step modifies original copied files.
- Package is used to design automatic login, sending, file transfer, injection, hook, memory reading, or endpoint-security bypass.
- Binary execution is deferred to a separately prepared runtime task.
- Original copied files lose their preserved evidence state.
- Package scope expands beyond evidence intake and source discovery.
- Any reader treats the offline package as a replacement for true N12.
**Next node:** N12R internal-sandbox live capture route if live UI evidence is needed. `N12-current` remains blocked in the outer-network environment.
@@ -848,7 +845,7 @@ Pass if:
- N11 accepted.
- Optional N12-pre offline evidence, if used, has been validated only as pre-gate context and not as live capture evidence.
- An internal-network test sandbox can manually open and log in to iSphere / IMPlatformClient using the normal approved process.
- An internal-network test sandbox can manually open and log in to iSphere / IMPlatformClient using the normal company login process.
- The internal sandbox has this repository or a trusted copy of the built helper/MCP artifacts.
- The operator follows `docs/internal-sandbox-operator-runbook.md`.
@@ -898,7 +895,7 @@ Pass if:
- The package satisfies `docs/n12r-return-package-validation-checklist.md`.
- `scan-windows.json` includes an iSphere / IMPlatformClient candidate window.
- `dump-uia-main.json` contains a UI Automation root tree for the selected window.
- The capture notes state the user manually logged in and no forbidden action was performed.
- The capture notes state the user manually logged in and record the exact capture scope.
- The redacted dump exists before broad sharing.
**Stop conditions:**
@@ -907,9 +904,9 @@ Pass if:
- No candidate window appears.
- UIA tree is empty or inaccessible.
- Package contains secrets or message contents that require redaction before review.
- Any step requires automatic login, clicking into conversations, sending, uploading, downloading, injection, hook, memory reading, or endpoint-security bypass.
- Any step expands beyond scan/dump evidence capture into business actions or low-level client manipulation.
**Next node:** N12R-review selector design review gate. Do not implement selectors until N12R evidence is accepted and a separate selector design plan is approved.
**Next node:** N12R-review selector design review gate after N12R evidence is accepted and a separate selector design plan exists.
---
@@ -925,10 +922,10 @@ This is the original N12 definition. It is retained as a blocked/non-active gate
- N11 accepted.
- Optional N12-pre offline evidence, if used, has been validated only as pre-gate context and not as N12 pass evidence.
- The repository environment can manually open and log in to iSphere / IMPlatformClient using the normal approved process.
- The repository environment can manually open and log in to iSphere / IMPlatformClient using the normal company login process.
- iSphere main window is visible in that same environment.
**Original allowed actions:**
**Original tool actions:**
Use only these Go MCP tools:
@@ -943,9 +940,9 @@ Save output under:
runs/real-loggedin-lab/uia-dumps/
```
**Current decision:** Do not wait on this gate as the active route. Use N12R internal-sandbox live capture instead.
**Current decision:** Active route uses N12R internal-sandbox live capture instead of waiting on this gate.
**Next node:** Future selector design may be considered only after N12R evidence is accepted, or after N12-current becomes possible and passes. Do not implement selectors before a separate selector design plan is approved.
**Next node:** Future selector design follows accepted N12R evidence, or a passed N12-current gate, plus a separate selector design plan.
---
@@ -953,28 +950,28 @@ runs/real-loggedin-lab/uia-dumps/
Every node must satisfy these rules:
1. No Python MCP code.
2. No real send/search/file action before an explicitly approved post-N12R selector/action plan.
3. No process injection, hook, credential extraction, token extraction, or memory reading.
4. No broad `git add -A`; stage exact paths.
1. Go MCP remains the active implementation path.
2. Core contact/group/message/file tools follow `docs/mcp-core-tools-contract.md`.
3. UIA/helper work stays behind `internal/helperclient` and supports source discovery or fallback connector work.
4. Stage exact paths.
5. Every code node uses tests first.
6. Every completion claim needs a fresh verification command.
7. Every failed gate stops the plan and requires a short report before continuing.
7. Every failed gate produces a short report before continuing.
---
## 4. Current Next Node
Current guidance after N0-N11 completion is:
Current guidance after N0-N15 remote updates is:
```text
N12-pre has been used for offline evidence. Because the current environment is outside the internal network, the active live-capture route is N12R internal-sandbox live capture. N12-current remains blocked unless network placement changes.
当前下一步MCP 核心工具合同冻结 + 只读消息源 discovery + N12-pre 证据包整理。
```
Immediate next action:
```text
If live UI evidence is needed, use `docs/internal-sandbox-live-capture-plan.md` and `docs/internal-sandbox-operator-runbook.md` to collect an N12R package from an internal-network test sandbox. Continue to use `docs/offline-evidence-intake-plan.md` only for offline file evidence.
Use `docs/mcp-core-tools-contract.md` and `docs/mcp-core-business-plan.md` as the active business route. Prioritize `isphere_search_contacts`, `isphere_search_groups`, `isphere_receive_messages`, and `isphere_receive_files`, then move `isphere_send_message` and `isphere_send_file` into Stage D after source discovery and connector verification.
```
Do not treat offline evidence as N12 pass. Do not start selector/action design until N12R live evidence is accepted, or until N12-current becomes possible and passes in the current environment.
N12-pre offline evidence remains pre-gate context. Existing UIA selector/report work is auxiliary evidence and fallback support only; the project core is MCP communication capability for contacts, groups, messages, and files.

View File

@@ -2,7 +2,7 @@
This runbook is for the first Go MCP phase of `isphere-ai-bridge`. It lets an operator build and verify the Windows helper and the Go MCP server without reading the source code.
Current scope: expose four read-only WinHelper operations through Go MCP. This phase does not automate iSphere login and does not perform message or file actions.
Current scope: expose four WinHelper observation operations, four business read tools, one message-send tool, and one preview-only file-send tool through Go MCP. Read tools are `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode plus download preview/blocked status. `isphere_send_message` is preview/dry-run by default; when explicitly configured with `ISPHERE_SEND_CONNECTOR_MODE=uia_rpa` it can perform a UI write/click action against a configured chat HWND, but online business delivery is still unverified. `isphere_send_file` remains preview/dry-run only and production file upload is blocked. Read tools use an empty default message source unless `ISPHERE_PACKET_LOG_FILE` points to an operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` points to an operator-local directory of encrypted PacketReader `.log`/`.txt` files. Contact/group display names can also be optionally enriched from an operator-local copied `MsgLib.db` through the bounded read-only `MsgLibReadSidecar`; receive messages can use copied DB rows only when the caller explicitly passes `source_preference="msglib_readonly"`.
## 1. Prerequisites
@@ -43,7 +43,7 @@ Run the helper verification script:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
```
Expected output includes `"ok":true`, helper version `0.1.0`, a window scan count, and a UIA availability value.
Expected output includes `"ok":true`, helper version `0.4.0`, a window scan count, send preflight smoke fields, and a UIA availability value.
## 4. Build Go MCP
@@ -90,12 +90,121 @@ The verification confirms:
- C# helper can be built.
- Go MCP binary can be built.
- MCP initialize/list/call flow works through the SDK harness.
- Exactly four tools are exposed.
- Exactly ten tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, `isphere_receive_files`, preview-only `isphere_send_message`, and preview-only `isphere_send_file`.
- `win_helper_version` returns `ISphereWinHelper`.
- No real iSphere login is required.
- No message/file/search action tool is present.
- `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source.
- `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`.
- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message.
- A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture.
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture; download preview is callable and returns `download_status="blocked"` with `blocked_reason_code="file_cache_mapping_missing"` when no accepted mapping exists.
- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; the default runtime still returns `execution_mode="production"` as `send_status="blocked"` and `production_send_enabled=false`.
- `isphere_send_file` is callable in preview mode when `ISPHERE_SEND_FILE_ALLOWED_DIR` points to the allowed local directory; it returns file SHA256/size metadata only, does not return file content, and keeps production upload blocked with `production_send_enabled=false`.
- `isphere_send_message` also exposes sandbox-only connector metadata and idempotency state: duplicate reuse of the same key for the same target/content is detectable, while conflicting reuse of the same key for different content/target is blocked.
- R6f adds a test-only fake/sandbox connector contract for accepted/failed connector outcomes. This is an injected unit-test boundary only; the production MCP server still registers no real connector and performs no real send.
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
- Real file download/copy, production message send, and production file upload remain later-stage work; file download preview now returns structured blocked/planned metadata with all copy/read side-effect flags false.
## 6. Configure MCP client command
## 6. Configure optional message source
By default, `isphere_receive_messages` is registered but uses an empty source, so it returns:
```json
{"messages":[]}
```
To point the server at a local encrypted PacketReader log-line file, set:
```powershell
$env:ISPHERE_PACKET_LOG_FILE = "E:\coding\codex\isphere-ai-bridge\runs\offline-evidence-intake\<evidence-id>\packet-reader-lines.txt"
```
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.
To point the server at a directory containing multiple local encrypted PacketReader files, set:
```powershell
$env:ISPHERE_PACKET_LOG_DIR = "E:\coding\codex\isphere-ai-bridge\runs\offline-evidence-intake\<evidence-id>\raw\temp\PacketReader"
```
The directory loader recursively reads `.log` and `.txt` files, sorts paths deterministically, and ignores blank lines. Set only one source variable at a time: if both `ISPHERE_PACKET_LOG_FILE` and `ISPHERE_PACKET_LOG_DIR` are set, server startup fails fast as an ambiguous configuration.
Then start the MCP server normally. The command entry point uses `ISPHERE_PACKET_LOG_FILE` when the file variable is set, otherwise `ISPHERE_PACKET_LOG_DIR` when the directory variable is set. The repeatable verification script checks three paths: default empty source, a temporary synthetic encrypted fixture file, and a temporary synthetic encrypted fixture directory.
## 7. Configure optional MsgLib display-name enrichment and explicit DB receive
This is optional. It enriches contact/group display metadata and can also serve `isphere_receive_messages` from copied `MsgLib.db` rows when the caller explicitly chooses `source_preference="msglib_readonly"`. It does not turn `MsgLib.db` into the default message source: empty or `auto` source preference still uses the PacketReader/log-backed source.
Build the bounded x86 sidecar first:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-msglib-sidecar.ps1
```
Then configure all three MsgLib variables before starting the MCP server:
```powershell
$env:ISPHERE_MSGLIB_SIDECAR_EXE = "E:\coding\codex\isphere-ai-bridge\runs\msglib-sidecar\MsgLibReadSidecar.exe"
$env:ISPHERE_MSGLIB_SQLITE_DLL = "<path-to-copied-System.Data.SQLite.dll>"
$env:ISPHERE_MSGLIB_DB = "<path-to-copied-MsgLib.db>"
```
Optional password override:
```powershell
$env:ISPHERE_MSGLIB_PASSWORD = "123"
```
Rules:
- Set all of `ISPHERE_MSGLIB_SIDECAR_EXE`, `ISPHERE_MSGLIB_SQLITE_DLL`, and `ISPHERE_MSGLIB_DB`, or set none of them. Partial MsgLib config fails server startup.
- Keep copied DB/provider files under ignored `runs/` paths or another operator-local location. Do not commit them.
- `MsgLibReadSidecar` is read-only and bounded to schema/display/message-list operations. It does not send messages, download files, mutate DB rows, log in, hook processes, or return raw rows.
- `source_preference="msglib_readonly"` is required to read messages from copied `MsgLib.db`; `auto` and empty source preference remain PacketReader/log-backed.
- Standard `scripts\verify-go-mcp.ps1` intentionally clears MsgLib variables to keep the default smoke deterministic.
Verify the optional provider path:
```powershell
$env:ISPHERE_MSGLIB_SQLITE_DLL = "<path-to-copied-System.Data.SQLite.dll>"
$env:ISPHERE_MSGLIB_DB = "<path-to-copied-MsgLib.db>"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-sidecar.ps1
```
Expected provider evidence contains `provider_checked=true`, a `display_source_count`, and sanitized `display_entity_summaries` with counts/source table names only.
Verify MCP-level optional enrichment, including receive-message display fields and explicit DB receive:
```powershell
$env:ISPHERE_MSGLIB_SQLITE_DLL = "<path-to-copied-System.Data.SQLite.dll>"
$env:ISPHERE_MSGLIB_DB = "<path-to-copied-MsgLib.db>"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-mcp-enrichment.ps1
```
Expected MCP evidence is sanitized and should include:
```json
{
"ok": true,
"db_receive_smoke": true,
"db_receive_message_count": 5,
"db_receive_sources": ["msglib:tblPersonMsg"],
"receive_display_smoke": true,
"receive_sender_name_populated": true,
"receive_conversation_display_populated": true,
"printed_entity_values": false,
"message_body_values_printed": false,
"message_bodies_returned": false,
"file_paths_returned": false,
"raw_rows_returned": false
}
```
The optional MCP smoke internally uses copied-DB display metadata to build a synthetic encrypted PacketReader fixture and explicitly calls `isphere_receive_messages` with `source_preference="msglib_readonly"`. The printed output must stay limited to counts, booleans, and `msglib:<source_table>` refs. It must not print entity values, JIDs, message body values, local DB paths, attachment paths, or raw rows.
## 8. Configure MCP client command
After building a stable binary, configure your MCP client to run the Go MCP executable as a stdio server.
@@ -130,47 +239,103 @@ If you used `go build ./cmd/isphere-mcp` without `-o`, point the command to:
E:\coding\codex\isphere-ai-bridge\isphere-mcp.exe
```
## 7. Allowed tools
## 9. Allowed tools
Only these four tools are allowed in the first phase:
These ten tools are currently allowed:
| Tool | Helper op | Purpose |
| Tool | Source/op | Purpose |
| --- | --- | --- |
| `win_helper_version` | `version` | Read helper version and protocol metadata. |
| `win_helper_self_check` | `self_check` | Read desktop/UIA availability status. |
| `win_helper_scan_windows` | `scan_windows` | Read visible window metadata or likely iSphere candidates. |
| `win_helper_dump_uia` | `dump_uia` | Read a UI Automation tree for a specified window handle. |
| `win_helper_version` | helper `version` | Read helper version and protocol metadata. |
| `win_helper_self_check` | helper `self_check` | Read desktop/UIA availability status. |
| `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. |
| `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. |
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source with a contract-shaped response envelope. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. |
| `isphere_search_contacts` | log-backed message JIDs plus optional MsgLib display enrichment | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_search_groups` | log-backed groupchat JIDs plus optional MsgLib display enrichment | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_receive_files` | log-backed file-transfer messages plus download preview resolver boundary | List file metadata candidates extracted from file-transfer message bodies. Download preview returns structured `blocked` or `planned` metadata; real file copy remains blocked. |
| `isphere_send_message` | B-route preview/dry-run contract | Plan a text-message send with target/content hash/idempotency/audit metadata. Production send is blocked. |
| `isphere_send_file` | file-send preview/dry-run contract | Plan a file send with target/file SHA256/size/idempotency metadata. Production upload is blocked. |
Allowed parameters:
- `win_helper_version`: no business parameters.
- `win_helper_self_check`: no business parameters.
- `win_helper_version`: zero business parameters.
- `win_helper_self_check`: zero business parameters.
- `win_helper_scan_windows`: `include_all_visible` only.
- `win_helper_dump_uia`: `hwnd`, `max_depth`, `include_text`, `max_children` only.
- `isphere_receive_messages`: `conversation_id`, `query`, `since`, `include_attachment_metadata`, `source_preference`, `preview`, `cursor`, `limit` only. `since` must be RFC3339/RFC3339Nano when supplied; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
- `isphere_search_contacts`: `query`, `cursor`, `source_preference`, `include_inactive`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
- `isphere_search_groups`: `query`, `cursor`, `source_preference`, `include_archived`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
- `isphere_receive_files`: `conversation_id`, `message_id`, `file_id`, `file_ref`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. List mode supports empty/`list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty. Download mode supports preview-only status with `mode="download"`, `preview=true`, `file_ref` or `file_id`, and optional `output_dir`; real copy remains blocked unless a future accepted resolver and copy gate are added.
- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. The default runtime returns `execution_mode="production"` as blocked until sandbox-send evidence passes. R6f adds a Go `SendMessageConnector` interface for fake/sandbox tests so accepted, failed, duplicate, and conflict paths can be validated without login. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key.
- `isphere_send_file`: `target_type`, `target_id`, `file_path`, `file_sha256`, `idempotency_key`, `execution_mode`, `preview` only. `file_path` must stay inside `ISPHERE_SEND_FILE_ALLOWED_DIR`; preview computes/validates `file_sha256` and `file_size_bytes`, returns `real_send_attempted=false`, and does not upload or return file content. The default runtime returns `execution_mode="production"` as blocked.
## 8. Explicit non-goals and safety boundaries
Optional send audit/idempotency paths:
The current phase has these boundaries:
```powershell
$env:ISPHERE_SEND_AUDIT_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-message-preview.jsonl"
$env:ISPHERE_SEND_IDEMPOTENCY_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-message-idempotency.jsonl"
```
- no login automation
- no send message
- no send file
- no receive/download file
- no process injection
- no hook
- no memory reading
- no search contacts
- no open conversation
- no automatic login
- no credential, token, cookie, or private-key extraction
- no endpoint-security bypass or evasion
- no Python MCP fallback
If `ISPHERE_SEND_IDEMPOTENCY_PATH` is set, the send preview path records hashed idempotency state. Reusing the same idempotency key for the same target/content sets `duplicate_detected=true`; reusing it for different content or target returns blocked metadata with `conflict_detected=true`. Raw message body and raw idempotency key are not stored in the JSONL state.
Future send/file operations, if ever approved, must be behind explicit human approval and audit records. They are not implemented in this phase.
Send-file preview requires an explicit allowed directory. Put only the files you want the digital employee to plan against inside that directory:
## 9. Real-login UIA capture procedure
```powershell
$env:ISPHERE_SEND_FILE_ALLOWED_DIR = "E:\coding\codex\isphere-ai-bridge\runs\send-file-preview"
```
This is a later real-login capture gate. Do not run it unless the operator has explicit approval for the real logged-in UIA capture step.
Optional send-file audit/idempotency paths:
```powershell
$env:ISPHERE_SEND_FILE_AUDIT_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-file-preview.jsonl"
$env:ISPHERE_SEND_FILE_IDEMPOTENCY_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-file-idempotency.jsonl"
```
`isphere_send_file` rejects `file_path` values outside that directory. It computes file SHA256 and size, but does not upload, click, type, or return file content. If `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH` is set, duplicate same-key/same-target/same-file preview calls set `duplicate_detected=true`; reusing the same key for a different target or file hash returns blocked metadata with `conflict_detected=true`. The audit/idempotency JSONL stores file hash, size, target ref, and hashed idempotency key; it does not store raw file content, raw local file path, or raw idempotency key.
R6g adds a local diagnostic for replaying the redacted audit/idempotency files without opening iSphere:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-audit-idempotency.ps1 `
-AuditPath $env:ISPHERE_SEND_AUDIT_PATH `
-IdempotencyPath $env:ISPHERE_SEND_IDEMPOTENCY_PATH
```
The diagnostic prints sanitized counts and booleans: `audit_records`, `idempotency_records`, `duplicate_detected`, `conflict_detected`, `raw_body_present`, and `raw_idempotency_key_present`. It exits non-zero if a raw message body field or raw idempotency-key field is found.
R6h centralizes the production-send gate. Default `execution_mode="production"` responses include machine-readable `gate_reason_code` and `gate_reason_message`. The valid gate codes are:
- `send_disabled_by_default`
- `send_evidence_missing`
- `send_connector_missing`
- `send_gate_passed`
`ISPHERE_SEND_PRODUCTION_ENABLED=1` is recognized by the gate policy, but it is not sufficient by itself: the evidence gate and connector gate must also pass before production can be allowed. In the default MCP server path, `production_send_enabled` remains `false`.
R6l closes the current send-message production path after R6k: because `strict_v2_pass=false`, production send remains blocked even if `ISPHERE_SEND_PRODUCTION_ENABLED=1` is set. In that state, the MCP response uses `gate_reason_code="send_evidence_missing"` and still reports all real side-effect flags as false.
## 10. Current tool surface and next-stage route
The current runbook verifies ten tools:
- `win_helper_version`
- `win_helper_self_check`
- `win_helper_scan_windows`
- `win_helper_dump_uia`
- `isphere_receive_messages`
- `isphere_search_contacts`
- `isphere_search_groups`
- `isphere_receive_files`
- `isphere_send_message`
- `isphere_send_file`
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation and download preview with machine-readable blocked/planned status; real download/copy remains blocked until accepted cache mapping and a separate copy gate exist. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, redacted audit, and a test-only `SendMessageConnector` contract for fake accepted/failed outcomes; `isphere_send_file` now exposes preview/dry-run file SHA256/size planning behind `ISPHERE_SEND_FILE_ALLOWED_DIR` with all upload/send side-effect flags false. The default MCP server still has no real message/file connector and production send/file upload remains blocked until dynamic observation, full evidence gates, and controlled sandbox connector passes prove success/ack behavior.
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; send-file preview metadata is in place with allowed-directory validation. Production message send, real file download/copy, and file upload still wait for later evidence gates.
## 11. Real-login UIA capture procedure
This is a later real-login capture gate used to collect live UI structure for connector work.
Procedure:
@@ -182,7 +347,7 @@ Procedure:
```
2. Manually open iSphere / IMPlatformClient.
3. Manually log in using the normal company-approved process.
3. Manually log in using the normal company login process.
4. Keep the main iSphere window visible.
5. Use `win_helper_scan_windows` to find the candidate window. Prefer read-only scan arguments such as:
@@ -201,7 +366,7 @@ Procedure:
}
```
7. Save approved evidence under:
7. Save selected evidence under:
```text
runs\real-loggedin-lab\uia-dumps\
@@ -209,18 +374,14 @@ Procedure:
8. Review and redact any sensitive content before sharing reports.
During this procedure, the operator must not click, type, send, upload, download, modify client state, or capture passwords/tokens.
## 10. If this outer-network environment cannot log in
Do not run or mark the original current-environment real-login UIA capture gate as passed.
## 12. If this outer-network environment cannot log in
iSphere is internal-network only. If the current repository environment is outside that network, use two separate routes:
1. **N12-pre offline evidence** for copied files and static analysis. Follow `docs/offline-evidence-intake-plan.md` and place packages under `runs\offline-evidence-intake\<evidence-id>\`.
2. **N12R internal-sandbox live capture** for real logged-in UIA evidence. Follow `docs/internal-sandbox-live-capture-plan.md` and `docs/internal-sandbox-operator-runbook.md`; place returned packages under `runs\internal-sandbox-live-capture\<capture-id>\`.
N12R is the active live-capture route when login is only possible in an internal-network test sandbox. It still allows only the four first-phase read-only tools:
N12R is the active live-capture route when login is only possible in an internal-network test sandbox. The UIA capture procedure still uses only the four helper observation tools:
```text
win_helper_version
@@ -229,6 +390,8 @@ win_helper_scan_windows
win_helper_dump_uia
```
The MCP server also registers the log-backed read tools (`isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files`), but their default source is empty and they should not be used as N12R UIA evidence collection.
After receiving an N12R package, validate it with `docs/n12r-return-package-validation-checklist.md` before using it as evidence for selector-design review. Prefer the scripted read-only validator:
```powershell
@@ -242,9 +405,225 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-retu
Keep the validation report outside `$captureRoot`; the returned package is evidence input and should remain unchanged.
Neither offline evidence nor N12R authorizes automatic login, search contacts, open conversation, send message, send file, receive file, injection, hook, memory reading, credential extraction, or endpoint-security bypass behavior.
## 13. R6c online sandbox send evidence package
## 11. Troubleshooting
The local development machine cannot log in to iSphere. Do not attempt to prove production send locally.
To prepare the online/internal sandbox evidence package:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-sandbox-gate.ps1
```
Verify the package locally:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-sandbox-gate-package.ps1
```
Expected output includes:
```json
{
"ok": true,
"local_login_required": false,
"online_sandbox_required": true,
"production_send_enabled": false
}
```
Generated package:
```text
runs\send-sandbox-gate-package.zip
```
Send that zip to the online/internal sandbox operator. The operator must follow `OPERATOR-STEPS.md` inside the package:
1. fill `SANDBOX-SEND-INPUTS.json`;
2. run `RUN-RECORDING-SUITE.bat` for the guided full flow, or run `01-run-before-recorder.bat`;
3. manually send exactly one sandbox message in the official client;
4. fill `success_ack_or_sent_record`;
5. continue the guided flow, or run `02-run-after-recorder.bat`;
6. continue the guided flow, or run `03-make-return-zip.bat`;
7. return the generated zip.
The package also contains `RECORDING-PACKAGE-MANIFEST.json`, `run-before-recorder.bat`, `run-after-recorder.bat`, `compute-content-hash.ps1`, `make-return-zip.ps1`, and the read-only recorder under `recorder\`.
Production `isphere_send_message` remains blocked until the returned package is analyzed.
## 14. Returned sandbox package validation
When a returned package comes back, validate it before treating it as evidence:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\validate-returned-send-sandbox-package.ps1 -PackagePath C:\Users\zhaoy\Downloads\1631.zip
```
For R6j strict-v2 packages, use:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\validate-returned-send-sandbox-package.ps1 -ZipPath C:\Users\zhaoy\Downloads\isphere-send-sandbox-return-20260710-180000.zip -StrictV2
```
The current partial returned package `C:\Users\zhaoy\Downloads\1631.zip` validates as:
```json
{
"partial_evidence_usable": true,
"r6d_gate_pass": false,
"strict_v2_pass": false,
"production_send_enabled": false,
"decision": "partial_use_only"
}
```
Use `partial_use_only` to continue local sandbox-only connector-shell and idempotency/audit work. Do not enable production send from a partial package. Full pass still requires after-recorder output, manual send timestamps, and the R6j strict-v2 fields: `operator_started_at_local`, `operator_clicked_send_at_local`, `operator_observed_success_at_local`, `before_recorder_present`, `after_recorder_present`, `success_ack_or_sent_record_present`, and `duplicate_second_send_attempted=false`.
The fixture test for the current partial package is:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-validate-returned-send-sandbox-package.ps1
```
## 15. Offline capability-test package
Use this second package when you want to test the current capability surface without logging in and without any real send.
Build and verify it locally:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-capability-test.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-capability-test-package.ps1
```
Generated package:
```text
runs\send-capability-test-package.zip
```
Inside the extracted package, run:
```text
run-capability-test.bat
```
Expected final JSON includes:
```json
{
"ok": true,
"tool_count": 10,
"helper_name": "ISphereWinHelper",
"send_preview_tool_present": true,
"production_send_enabled": false,
"send_file_preview_tool_present": true,
"send_file_production_enabled": false,
"local_login_required": false,
"no_real_send": true
}
```
This package includes `isphere-capability-smoke.exe` and `runs\win-helper\ISphereWinHelper.exe`. It verifies the ten-tool MCP surface, fixture-backed receive/search/file-list behavior, send preview, send-file preview, and the production-send/file-upload blocks. It does not log in, click, type, upload, hook, inject, or send.
## 16. Business goals smoke
Run the business-facing smoke when you need a single status line for the four goals:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-business-goals-smoke.ps1
```
It reuses `scripts\verify-go-mcp.ps1`, writes `docs/reports/2026-07-10-business-goals-smoke.md`, and prints these key fields:
- `search_contacts_ready`
- `search_groups_ready`
- `receive_messages_ready`
- `send_message_preview_ready`
- `send_message_production_ready`
- `receive_files_list_ready`
- `receive_files_download_ready`
- `send_file_preview_ready`
- `send_file_production_ready`
- `remaining_business_blockers`
Current expected production blockers are real send-message, real file download, and real file upload.
## 17. R14 release-candidate status
Use the R14 release-candidate report when you need a business-facing answer about what is ready, what is preview-only, and what still needs online evidence:
```text
docs\reports\2026-07-10-r6f-r14-release-candidate.md
```
Current short answer:
- 已完成:搜索联系人、搜索群组、收消息基础读取、收文件列表。
- 可预览:发消息 preview、发文件 preview。
- A-route/RPA 是备选兜底,不是主线;主线仍是 B-route running-client sidecar / in-process connector / existing API。
- A-route 已具备 UI 动作能力:显式配置后可以对聊天窗口写入文本并点击发送按钮;本地还可打开真实离线 `frmP2PChat` 验证窗口/输入框/按钮定位。
- 阻断:在线业务送达、真实文件下载、生产发文件。离线点击不等于服务端发送成功。
The two online evidence packages to send to an internal/logged-in operator are:
```text
runs\send-sandbox-gate-package.zip
runs\send-file-sandbox-gate-package.zip
```
They are generated under ignored `runs\` and are not committed.
## 18. A-route UIA send connector
A-route is the UI Automation fallback for text sending when the B-route sidecar cannot be tested. It is function-first, but it is not the primary architecture. Prefer B-route running-client sidecar / in-process connector / existing API evidence whenever that route is available. There is no human approval ID gate inside the connector; the digital employee layer is responsible for deciding when to call production mode.
Local verification uses a synthetic WinForms window:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
```
Expected output includes helper version `0.5.0` and:
```json
{"synthetic_uia_send_action":"uia_send_message"}
```
The project also has a real offline chat-window opener:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps -ShowWindow -HoldSeconds 1
```
That script opens the real `frmP2PChat` without login and confirms the key UIA
ids `frmP2PChat`, `rtbSendMessage`, `btnSend`, and `rtbRecvMessage`. The window
is still offline and will show the client-side “cannot send while offline”
message.
To enable A-route in a logged-in desktop session, configure the MCP server environment before startup:
```powershell
$env:ISPHERE_SEND_CONNECTOR_MODE = "uia_rpa"
$env:ISPHERE_SEND_UIA_HWND = "0x001A0B2C"
$env:ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID = "rtbSendMessage"
$env:ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID = "btnSend"
# optional if not using runs\win-helper\ISphereWinHelper.exe
$env:ISPHERE_SEND_UIA_HELPER_PATH = "E:\coding\codex\isphere-ai-bridge\runs\win-helper\ISphereWinHelper.exe"
```
Then call `isphere_send_message` with `execution_mode="production"`. The connector will set the send editor text and invoke the send button through UI Automation. Response metadata uses `connector_mode="uia-rpa"`; the audit still stores hashes and metadata, not the raw message body or raw idempotency key.
Important status boundary: helper fields such as `clicked_ui=true` or
`sent_message=true` mean the UI action ran. They do not prove server acceptance,
receiver delivery, or a sent-record match. Online/logged-in evidence is still
required before this can be called business-level send.
Standard `scripts\verify-go-mcp.ps1` clears all A-route env vars so the deterministic smoke remains preview/blocked by default.
## 19. Troubleshooting
- If C# helper build fails, run `scripts\build-win-helper.ps1` directly and check for missing .NET Framework reference assemblies.
- If `win_helper_version` fails, rerun `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1` first.

View File

@@ -0,0 +1,141 @@
# MCP Core Business Plan
Date: 2026-07-09
## Business correction
项目核心不是继续做 UIA selector/report而是通过 MCP 暴露 iSphere 的联系人、群组、消息、文件能力。
后续研发主线切换为 MCP 核心通信能力:搜索联系人、搜索群组、收发消息、收发文件。现有 UIA/selector/report 只作为底层辅助和证据,不再作为主线继续扩展。
## Core business capabilities
The product roadmap is limited to six business capabilities:
1. Search contacts.
2. Search groups.
3. Receive/read messages.
4. Send messages.
5. Receive/download files.
6. Send files.
Everything else is infrastructure or fallback support. Selector catalog/report validation may support UI automation if needed, but it is not the main product deliverable.
## Capability source priority
Use this priority order when searching for real implementations:
1. Existing bridge / API / local service.
2. Local database / log / cache read-only access.
3. UI automation.
4. Network/API analysis.
5. Reverse engineering, only for explanation and validation, not as the default route.
Implications:
- Prefer an existing bridge/API/local service before reading local storage or using UIA.
- Prefer read-only database/log/cache access before UI automation when it can answer the business question.
- UIA selector work is a fallback access mechanic, not the primary roadmap.
- Invest in selector/report work only when it directly unblocks a concrete core MCP tool.
- Reverse engineering is last resort and should explain formats or validate assumptions; it should not become the default delivery path.
## Stage A: Freeze MCP core tools contract
Goal: lock the business-facing MCP tool surface before adding more helper or selector work.
Deliverables:
- `docs/mcp-core-tools-contract.md` exists and defines:
- `isphere_search_contacts`
- `isphere_search_groups`
- `isphere_receive_messages`
- `isphere_send_message`
- `isphere_receive_files`
- `isphere_send_file`
- Write-capable tools expose preview and execution metadata.
- Read tools come before write tools so the capability source is proven first.
Acceptance:
- Contract names, input parameters, output JSON, error codes, audit fields, read/write status, and implementation priority are explicit.
- Selector/report work is not the next primary step.
## Stage B: Find real capability sources
Goal: identify the best source for each read-only capability.
Tasks:
1. Inventory existing bridge/API/local service artifacts and current Go/C# boundaries.
2. Re-check whether existing bridge endpoints or local services can provide contacts, groups, messages, or files.
3. Re-check local database/log/cache artifacts only in read-only mode.
4. Use UIA only if higher-priority sources cannot satisfy the read-only tool.
5. Document source decisions per tool before implementation.
Acceptance:
- Each read-only tool has a selected primary source and a fallback source.
- The decision is evidence-backed and does not rely on stale prior notes without current verification.
## Stage C: Read-only loop
Goal: deliver useful MCP read capability before any real write capability.
Implementation order:
1. `isphere_search_contacts`
2. `isphere_search_groups`
3. `isphere_receive_messages`
4. `isphere_receive_files`
Acceptance:
- Each tool can return normalized JSON through MCP.
- Each tool records audit metadata.
- File receiving/download writes to a configured local output directory.
- Tests and verification cover connector behavior without expanding into selector/report-only work.
## Stage D: Write capability
Goal: add write behavior only after the read-only loop is proven.
Implementation order:
1. `isphere_send_message` preview first.
2. Audit and execution metadata.
3. `isphere_send_message` production write mode.
4. `isphere_send_file` preview first.
5. `isphere_send_file` production write mode.
Acceptance:
- Every write records target, content/file hash, operator, time, connector, and result.
- Idempotency prevents accidental duplicate sends.
- Preview output is available before production execution.
## Stage E: Pre-production audit and deployment acceptance
Goal: make the MCP service operable before broader use.
Checks:
- Tool registry and source registry are explicit.
- Audit records are durable and redacted where needed.
- Execution records are traceable.
- Deployment docs include exact commands, rollback, logs, and known limits.
Acceptance:
- Full test suite passes.
- Operator runbook covers normal operation and failure modes.
- A business user can understand which of the six capabilities are supported and which remain staged.
## Immediate next action
Current next step is:
```text
MCP core tools contract freeze + read-only message source discovery + N12-pre evidence package organization.
```
N16/N17 selector/report hardening is not the mainline. If UIA is needed later, use the existing N13/N14/N15 results as auxiliary evidence only.

View File

@@ -0,0 +1,376 @@
# MCP Core Tools Contract
Date: 2026-07-09
## Route statement
项目核心不是继续做 UIA selector/report而是通过 MCP 暴露 iSphere 的联系人、群组、消息、文件能力。
This contract defines the business-facing MCP tool surface. The existing `win_helper_*` tools remain low-level auxiliary tools for controlled read-only observation and fallback implementation work. They are not the product API.
## Implementation staging
- Build contact, group, message, and file read paths first because they unlock the digital employee workflow.
- Treat send message and send file as write-capable tools with preview, execution metadata, and audit fields.
- Use UIA selectors only where higher-priority sources cannot provide the required business capability.
## Common error codes
| Code | Meaning |
| --- | --- |
| `INVALID_ARGUMENT` | Required parameter missing or invalid. |
| `SOURCE_UNAVAILABLE` | Selected bridge/API/database/UIA source is unavailable. |
| `NOT_LOGGED_IN` | A required manually logged-in client session is not present. |
| `PERMISSION_DENIED` | Current operator/account is not authorized for the requested object. |
| `WRITE_NOT_READY` | The selected write connector is not ready for execution. |
| `PREVIEW_ONLY` | Tool returned a preview instead of executing the write. |
| `TARGET_NOT_FOUND` | Contact, group, conversation, message, or file was not found. |
| `AMBIGUOUS_TARGET` | Multiple possible targets match and the request must be narrowed. |
| `TIMEOUT` | The connector did not complete before timeout. |
| `CONNECTOR_FAILED` | Underlying source or helper returned a structured failure. |
| `INTERNAL_ERROR` | Unexpected implementation error. |
## Common audit fields
Every tool response should include an `audit` object:
```json
{
"tool": "isphere_search_contacts",
"request_id": "uuid",
"source": "bridge|local_readonly|uia|network|mock",
"operator_id": "optional operator identity",
"execution_mode": "read|preview|production",
"target_ref": "optional stable target reference",
"content_sha256": null,
"file_sha256": null,
"started_at": "ISO-8601",
"finished_at": "ISO-8601",
"result": "ok|error"
}
```
## Tool: `isphere_search_contacts`
| Field | Value |
| --- | --- |
| Business purpose | Search iSphere contacts so a digital employee can resolve a person before reading or drafting work. |
| Read-only | Yes. |
| Execution mode | Query preview or normal read. |
| Current priority | Stage C read-only loop, first priority. |
Input parameters:
```json
{
"query": "string, required",
"limit": "integer, optional, default 20, max 100",
"cursor": "string, optional",
"source_preference": "bridge|local_readonly|uia|network|auto, optional, default auto",
"include_inactive": "boolean, optional, default false"
}
```
Output JSON:
```json
{
"ok": true,
"contacts": [
{
"contact_id": "string",
"display_name": "string",
"account": "string|null",
"department": "string|null",
"title": "string|null",
"source": "bridge|local_readonly|uia|network|mock",
"confidence": 0.98,
"raw_ref": "redacted source reference"
}
],
"next_cursor": null,
"audit": {}
}
```
Errors: `INVALID_ARGUMENT`, `SOURCE_UNAVAILABLE`, `NOT_LOGGED_IN`, `PERMISSION_DENIED`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Tool: `isphere_search_groups`
| Field | Value |
| --- | --- |
| Business purpose | Search iSphere groups so a digital employee can resolve a group conversation before reading or drafting work. |
| Read-only | Yes. |
| Execution mode | Query preview or normal read. |
| Current priority | Stage C read-only loop, first priority. |
Input parameters:
```json
{
"query": "string, required",
"limit": "integer, optional, default 20, max 100",
"cursor": "string, optional",
"source_preference": "bridge|local_readonly|uia|network|auto, optional, default auto",
"include_archived": "boolean, optional, default false"
}
```
Output JSON:
```json
{
"ok": true,
"groups": [
{
"group_id": "string",
"display_name": "string",
"member_count": "integer|null",
"owner_ref": "string|null",
"source": "bridge|local_readonly|uia|network|mock",
"confidence": 0.96,
"raw_ref": "redacted source reference"
}
],
"next_cursor": null,
"audit": {}
}
```
Errors: `INVALID_ARGUMENT`, `SOURCE_UNAVAILABLE`, `NOT_LOGGED_IN`, `PERMISSION_DENIED`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Tool: `isphere_receive_messages`
| Field | Value |
| --- | --- |
| Business purpose | Read recent iSphere messages and normalize them for digital employee reasoning and drafting. |
| Read-only | Yes. |
| Execution mode | Source/query preview or normal read. |
| Current priority | Stage C read-only loop, first priority after contact/group source discovery. |
Input parameters:
```json
{
"conversation_id": "string, optional when query is supplied",
"conversation_type": "direct|group|system|unknown, optional",
"query": "string, optional target name or keyword",
"since": "ISO-8601 timestamp, optional",
"limit": "integer, optional, default 50, max 200",
"cursor": "string, optional",
"include_attachment_metadata": "boolean, optional, default true",
"source_preference": "bridge|local_readonly|uia|network|auto, optional, default auto",
"preview": "boolean, optional, default false"
}
```
Output JSON:
```json
{
"ok": true,
"conversation": {
"conversation_id": "string",
"conversation_type": "direct|group|system",
"display_name": "string"
},
"messages": [
{
"message_id": "string",
"sender_id": "string|null",
"sender_name": "string|null",
"content_type": "text|image|file|rich|notify|unknown",
"content_text": "authorized text or redacted summary",
"attachments": [
{
"file_id": "string",
"file_name": "string",
"size_bytes": "integer|null",
"download_ref": "string|null"
}
],
"created_at": "ISO-8601|null",
"received_at": "ISO-8601|null",
"source": "bridge|local_readonly|uia|network|mock",
"raw_ref": "redacted source reference"
}
],
"next_cursor": null,
"audit": {}
}
```
Errors: `INVALID_ARGUMENT`, `SOURCE_UNAVAILABLE`, `NOT_LOGGED_IN`, `PERMISSION_DENIED`, `TARGET_NOT_FOUND`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Tool: `isphere_receive_files`
| Field | Value |
| --- | --- |
| Business purpose | List and receive/download files from messages while preserving the iSphere client state. |
| Read-only | Yes for source/client state. It writes downloaded files to a configured local output directory. |
| Execution mode | List/download preview or normal download. |
| Current priority | Stage C read-only loop after messages expose file metadata. |
Implementation note: if the implementation becomes clearer with two internal operations, split into `list_files` and `download_file` behind this business contract. Keep the roadmap focused on receive/download.
Input parameters:
```json
{
"conversation_id": "string, optional",
"message_id": "string, optional",
"file_id": "string, optional for download, omitted for list",
"mode": "list|download, optional, default list",
"output_dir": "string, optional, required for download",
"limit": "integer, optional, default 50, max 200",
"cursor": "string, optional",
"source_preference": "bridge|local_readonly|uia|network|auto, optional, default auto",
"preview": "boolean, optional, default true for download"
}
```
Output JSON:
```json
{
"ok": true,
"mode": "list|download",
"files": [
{
"file_id": "string",
"message_id": "string|null",
"conversation_id": "string|null",
"file_name": "string",
"size_bytes": "integer|null",
"mime_type": "string|null",
"created_at": "ISO-8601|null",
"download_ref": "string|null",
"saved_path": "string|null",
"sha256": "string|null",
"source": "bridge|local_readonly|uia|network|mock"
}
],
"next_cursor": null,
"audit": {}
}
```
Errors: `INVALID_ARGUMENT`, `SOURCE_UNAVAILABLE`, `NOT_LOGGED_IN`, `PERMISSION_DENIED`, `TARGET_NOT_FOUND`, `PREVIEW_ONLY`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Tool: `isphere_send_message`
| Field | Value |
| --- | --- |
| Business purpose | Send a text message to a resolved contact or group. |
| Read-only | No. |
| Execution mode | Preview/dry-run now; production write after sandbox-send gate. |
| Current priority | R6b preview/dry-run contract is implemented; production remains blocked. |
Input parameters:
```json
{
"target_type": "direct|group, required",
"target_id": "string, required",
"content_text": "string, required",
"content_sha256": "sha256 hex of content_text, required",
"idempotency_key": "string, required; stored only as SHA256 in audit",
"execution_mode": "preview|production, optional, default preview",
"source_preference": "not accepted in R6b preview-only implementation"
}
```
Output JSON:
```json
{
"ok": true,
"execution_mode": "preview",
"send_status": "planned|blocked",
"connector": "implatform-sidecar",
"connector_stage": "preview|blocked",
"target": {
"target_type": "direct|group",
"target_id": "string",
"target_ref": "contact:<id>|group:<id>"
},
"content": {
"content_sha256": "sha256 hex",
"content_length": 12
},
"idempotency": {
"idempotency_key_sha256": "sha256 hex"
},
"side_effects": {
"sent_message": false,
"sent_file": false,
"uploaded_file": false,
"clicked_ui": false,
"typed_text": false,
"captured_network": false,
"attached_hook": false,
"modified_client_data": false
},
"audit": {}
}
```
R6b rule: the response and audit may include `content_sha256`, `content_length`, `idempotency_key_sha256`, `target_ref`, connector metadata, and side-effect booleans. They must not include raw message body or raw idempotency key. `execution_mode="production"` returns `ok=false` and `send_status="blocked"` until dynamic observation, idempotency/audit verification, and one approved sandbox send pass.
Errors: `INVALID_ARGUMENT`, `WRITE_NOT_READY`, `PERMISSION_DENIED`, `NOT_LOGGED_IN`, `TARGET_NOT_FOUND`, `AMBIGUOUS_TARGET`, `PREVIEW_ONLY`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Tool: `isphere_send_file`
| Field | Value |
| --- | --- |
| Business purpose | Send a local file to a resolved contact or group. |
| Read-only | No. |
| Execution mode | Preview or production write. |
| Current priority | Stage D after `isphere_send_message`. |
Input parameters:
```json
{
"target_type": "contact|group|conversation, required",
"target_id": "string, required",
"file_path": "string, required; use configured outbound directory",
"caption": "string, optional",
"execution_mode": "preview|production, optional, default preview",
"idempotency_key": "string, optional but recommended",
"source_preference": "bridge|uia|network|auto, optional, default auto"
}
```
Output JSON:
```json
{
"ok": true,
"execution_mode": "preview",
"send_status": "planned|sent|rejected",
"target": {
"target_type": "contact|group|conversation",
"target_id": "string",
"display_name": "string|null"
},
"file_ref": null,
"file_sha256": "sha256 hex",
"file_size_bytes": 12345,
"audit": {}
}
```
Errors: `INVALID_ARGUMENT`, `WRITE_NOT_READY`, `PERMISSION_DENIED`, `NOT_LOGGED_IN`, `TARGET_NOT_FOUND`, `AMBIGUOUS_TARGET`, `PREVIEW_ONLY`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Stage priority summary
| Priority | Tool | Stage |
| --- | --- | --- |
| 1 | `isphere_search_contacts` | C read-only loop |
| 2 | `isphere_search_groups` | C read-only loop |
| 3 | `isphere_receive_messages` | C read-only loop |
| 4 | `isphere_receive_files` | C read-only loop |
| 5 | `isphere_send_message` | D write capability |
| 6 | `isphere_send_file` | D write capability |

View File

@@ -0,0 +1,343 @@
# MsgLib read-only sidecar contract
Date: 2026-07-10
## Purpose
`MsgLibReadSidecar` is the bounded 32-bit .NET process used by the Go MCP layer to access copied or operator-local `MsgLib.db` files through the client-compatible `System.Data.SQLite` provider.
It exists because C26 proved the database opens with the bundled 32-bit SQLite provider and password `123`, while a 64-bit probe fails at provider loading. Go should coordinate the process; the sidecar owns only the narrow Windows/.NET DB read boundary.
## Protocol
- Protocol id: `isphere.msglib.v1`
- Transport: one JSON request on stdin, one JSON response on stdout.
- Process architecture: x86.
- Database mode: read-only only.
- Raw rows: never returned.
- Message bodies: returned only when a future caller explicitly sets `list_messages.include_body=true`; current verification and MCP defaults keep it `false`.
- DB mutation: never allowed.
Request envelope:
```json
{
"protocol": "isphere.msglib.v1",
"request_id": "example-1",
"op": "self_check",
"args": {}
}
```
Response envelope:
```json
{
"protocol": "isphere.msglib.v1",
"request_id": "example-1",
"op": "self_check",
"ok": true,
"data": {},
"error": null,
"warnings": []
}
```
## Operations
### `self_check`
Returns sidecar identity, protocol, runtime, process bitness, and capability flags.
Key response fields:
- `sidecar_name`
- `sidecar_version`
- `protocol`
- `runtime`
- `process_bits`
- `requires_process_bits`
- `db_mutation_allowed=false`
- `message_body_reads_allowed=true` for explicit `list_messages.include_body` opt-in; verification keeps it disabled.
### `schema_summary`
Validates provider load and read-only DB open, then returns schema metadata only.
Required args:
- `sqlite_dll_path`: path to bundled `System.Data.SQLite.dll`.
- `db_path`: path to a copied/operator-local `MsgLib.db`.
Optional args:
- `password`: default `123`.
- `include_counts`: default `true`; returns row counts only for target tables.
- `max_tables`: default `128`, clamped to `1..256`.
- `target_tables`: optional list; defaults to known display/message/file metadata tables.
Returned data:
- `metadata_only=true`
- `read_only=true`
- `message_body_values_returned=false`
- `sqlite_version`
- `tables`: table/view names only.
- `columns`: table column names/types only.
- `target_row_counts`: safe row counts for target tables only.
### `display_sources`
Same safe provider/DB validation as `schema_summary`, but additionally maps schema availability to product-facing display source candidates.
Returned data adds:
- `display_sources[]` entries with:
- `source`
- `table`
- `available`
- `required_columns`
- `present_columns`
Initial display sources:
- `contacts_roster` from `TD_Roster`.
- `contacts_effigy` from `TD_CustomEffigy`.
- `recent_display` from `tblRecent`.
- `person_message_names` from `tblPersonMsg`.
- `group_message_names` from `tblMsgGroupPersonMsg`.
- `work_group_auth` from `TD_WorkGroupAuth`.
- `received_file_metadata` from `TD_ReceiveFileRecord`.
- `sent_file_metadata` from `TD_SendFileRecord`.
### `display_entities`
Same safe provider/DB validation as `display_sources`, but returns bounded normalized contact/group display metadata.
Required args:
- `sqlite_dll_path`
- `db_path`
- `entity_type`: `contacts` or `groups`
Optional args:
- `password`: default `123`.
- `query`: optional string matched against allowlisted jid/name columns.
- `limit`: bounded to `1..100`; default `25`.
Returned data adds:
- `metadata_only=true`
- `read_only=true`
- `message_body_values_returned=false`
- `raw_rows_returned=false`
- `entities[]` entries with:
- `entity_type`
- `source_table`
- `jid`
- `display_name`
- `confidence`
- `matched_columns`
Initial allowlisted identity sources:
- Contacts: `TD_Roster(JId,Nick)`, `TD_CustomEffigy(PersonJid,PersonName)`, `tblRecent(PersonId,PersonName)`, `tblChatLevel(PersonId,PersonName)`, selected `tblPersonMsg` id/name pairs only.
- Groups: `TD_WorkGroupAuth(GroupJID,GroupName)`, `TD_WorkGroupAuth(ChildGroupJid,GroupName)`, selected `tblMsgGroupPersonMsg` group/session id/name pairs only.
### `message_sources`
Same safe provider/DB validation as `display_sources`, but reports whether candidate message tables are ready for a future DB-backed `isphere_receive_messages` source.
Required args:
- `sqlite_dll_path`
- `db_path`
Optional args:
- `password`: default `123`.
Returned data adds:
- `metadata_only=true`
- `read_only=true`
- `message_body_values_returned=false`
- `raw_rows_returned=false`
- `file_paths_returned=false`
- `message_sources[]` entries with:
- `source`
- `table`
- `role`
- `available`
- `required_columns`
- `present_columns`
- `row_count`
Initial message source candidates:
- `direct_messages` from `tblPersonMsg`.
- `group_messages` from `tblMsgGroupPersonMsg`.
- `system_messages` from `TD_SystemMessageRecord`.
- `group_receipts` from `TD_GroupReceiptMessage`.
- `received_file_metadata` from `TD_ReceiveFileRecord`.
- `recent_conversation_index` from `tblRecent`.
### `list_messages`
Same safe provider/DB validation as `message_sources`, but returns a bounded normalized message list for future DB-backed receive-message work. C38 keeps this at sidecar/Go-wrapper level only; it is not wired into MCP receive by default.
Required args:
- `sqlite_dll_path`
- `db_path`
Optional args:
- `password`: default `123`.
- `conversation_id`: optional metadata filter.
- `conversation_type`: `auto`, `direct`, `group`, or `system`; default `auto`.
- `query`: optional metadata filter. Body columns are searched only when `include_body=true`.
- `since`: optional timestamp lower bound using the DB timestamp column representation.
- `cursor`: unsupported initially; any non-empty value is rejected.
- `limit`: bounded to `1..50`; default `20`.
- `include_body`: default `false`.
- `include_attachment_metadata`: default `false`; returns filename/size metadata only, never path values or download refs.
Returned data adds:
- `read_only=true`
- `message_body_values_returned`: equals `include_body`.
- `raw_rows_returned=false`
- `file_paths_returned=false`
- `source_tables[]`
- `messages[]` entries with normalized fields:
- `message_id` / `id`
- `conversation_id`
- `conversation_type`
- `sender_id`
- `sender_name`
- `receiver_id`
- `text` / `content_text` (empty when `include_body=false`)
- `content_type`
- `timestamp` / `created_at`
- `subject`
- `read`
- `source=local_readonly`
- `raw_ref=msglib:<table>`
- `attachments[]` with `file_id`, `file_name`, `size_bytes`, and empty `download_ref`
- `next_cursor=null`
Initial allowlisted message sources:
- Direct: `tblPersonMsg`.
- Group: `tblMsgGroupPersonMsg`.
- System: `TD_SystemMessageRecord`.
- Attachment metadata: `TD_ReceiveFileRecord`, with `FilePath`/path-like values intentionally excluded.
## Security and scope rules
- The sidecar must open DB files with read-only connection settings.
- `db_path` must point to a file named `MsgLib.db`.
- The schema/display-source operations return schema, columns, counts, and display-source availability only.
- `display_entities` may return bounded contact/group `jid` and `display_name` metadata only.
- `message_sources` may return message-source table names, required/present column names, roles, availability flags, and row counts only.
- `list_messages` may return bounded normalized message metadata. It returns `text`/`content_text` only when `include_body=true`; the standard verification path uses `include_body=false`.
- It does not return file path values, attachment contents, raw row sets, sends, downloads, hooks, injection, or DB writes.
- It does not implement send, file download, mark-read, login, hooks, injection, or DB writes.
- The Go MCP layer remains the coordinator; this process is a bounded local reader.
## Current implementation
Committed C27 implementation:
- Source: `native/MsgLibReadSidecar/`.
- Build script: `scripts/build-msglib-sidecar.ps1`.
- Verification script: `scripts/verify-msglib-sidecar.ps1`.
- Default verification builds an x86 executable and checks `self_check`.
- Evidence-backed verification with C26 paths checked provider load/read-only DB open and returned 8 display-source candidates.
## Go client wrapper
Committed C28 implementation:
- Source: `internal/msglib/`.
- `ConfigFromEnv` reads `ISPHERE_MSGLIB_SIDECAR_EXE`, `ISPHERE_MSGLIB_SQLITE_DLL`, `ISPHERE_MSGLIB_DB`, and optional `ISPHERE_MSGLIB_PASSWORD`.
- `NewClient` creates a process-backed client for protocol `isphere.msglib.v1`.
- `SelfCheck` calls `self_check` and decodes sidecar identity, protocol, bitness, and read-only/no-mutation flags.
- `DisplaySources` calls `display_sources` and decodes display-source availability metadata.
- The client uses stdin/stdout JSON, request ids, `context.Context`, per-call timeout, stderr capture, and typed `SidecarError` values.
- Unit tests use a fake sidecar process; normal tests do not require real `System.Data.SQLite.dll` or copied `MsgLib.db`.
- C28 does not register a new MCP tool and does not return message bodies, file paths, raw rows, contact row values, or group row values.
Committed C29 implementation:
- Sidecar op: `display_entities`.
- Go wrapper: `DisplayEntities(ctx, DisplayEntitiesOptions)`.
- Verification script calls `display_entities` only when copied DB env paths are supplied, and prints only sanitized `entity_count` plus `source_tables` summaries.
- Evidence-backed C29 verification against the copied DB returned contact/group entity counts and source-table names without printing entity values.
- C31 adds optional `scripts/verify-msglib-mcp-enrichment.ps1` to prove MCP-level contact/group enrichment through the real sidecar while printing only counts and `msglib:<source_table>` refs. C33 extends the same optional script to create a synthetic encrypted PacketReader fixture from internally selected MsgLib contact/group metadata and verify receive-message `sender_name` plus `conversation.display_name` enrichment; the printed result remains limited to counts, booleans, and source refs.
Committed C36 implementation:
- Sidecar op: `message_sources`.
- Go wrapper: `MessageSources(ctx)`.
- Verification script calls `message_sources` only when copied DB env paths are supplied, and prints only sanitized `message_source_count` plus message-source summaries containing source/table/role/availability/row-count metadata.
- Evidence-backed C36 verification against the copied DB returned six message-source summaries without printing message body values, file path values, raw rows, or copied DB contents.
Committed C38 implementation:
- Sidecar op: `list_messages`.
- Go wrapper: `ListMessages(ctx, ListMessagesOptions)`.
- The op reads only allowlisted direct/group/system message tables and optional received-file metadata from a copied `MsgLib.db`, clamps `limit` to `1..50`, rejects non-empty cursor, and never returns raw rows or file path values.
- `scripts/verify-msglib-sidecar.ps1` calls `list_messages` only when copied DB env paths are supplied, with `include_body=false`, and prints only `message_list_count`, `message_list_sources`, safety booleans, and omission booleans.
- Evidence-backed C38 smoke returned normalized message count/source-table evidence without printing message text, body values, file paths, download refs, raw rows, sends, downloads, hooks, injection, or DB writes.
Committed C39 implementation:
- Go adapter: `internal/isphere.MsgLibMessageSource`.
- The adapter uses an injected `ListMessages` provider and maps sidecar-normalized messages into the existing receive-message domain model.
- It preserves explicit `source`/`raw_ref` values and bounded attachment metadata while rejecting provider results that report non-read-only mode, raw rows, or file path values.
- It is not wired into MCP default receive behavior; `auto` remains PacketReader/log-backed until an explicit source-selection loop is completed.
Committed C40 implementation:
- Tool-layer selector: `RegisterISphereReadToolsWithReceiveSources`.
- `source_preference=""`, `auto`, and `local_readonly` continue to use the primary PacketReader/log-backed source.
- `source_preference="msglib_readonly"` can select an explicitly configured MsgLib receive source and is rejected when that source is absent.
- Server env wiring is deferred to C41, so standard MCP startup and verification remain deterministic.
Committed C41 implementation:
- Server env wiring now constructs one MsgLib sidecar client for both display enrichment and explicit receive selection when all MsgLib env vars are present.
- `isphere_receive_messages` with `source_preference="msglib_readonly"` uses `internal/isphere.MsgLibMessageSource`; default `auto` still uses PacketReader/log-backed source.
- `scripts/verify-msglib-mcp-enrichment.ps1` includes an optional copied-DB explicit DB receive smoke and prints only counts, `msglib:<source_table>` refs, and booleans.
- Standard `scripts/verify-go-mcp.ps1` continues to clear MsgLib env and remains deterministic.
## Verification
Default verification builds and checks `self_check` only:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-sidecar.ps1
```
Evidence-backed provider verification is opt-in through environment variables and should use copied DB files only. The script also verifies `display_sources` and sanitized `display_entities` summaries when these variables are present:
```powershell
$env:ISPHERE_MSGLIB_SQLITE_DLL = "<path-to-System.Data.SQLite.dll>"
$env:ISPHERE_MSGLIB_DB = "<path-to-copied-MsgLib.db>"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-sidecar.ps1
```
Optional MCP enrichment smoke, including receive-message display enrichment, is also opt-in through the same copied DB environment variables:
```powershell
$env:ISPHERE_MSGLIB_SQLITE_DLL = "<path-to-System.Data.SQLite.dll>"
$env:ISPHERE_MSGLIB_DB = "<path-to-copied-MsgLib.db>"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-mcp-enrichment.ps1
```
Expected printed evidence is sanitized: contact/group counts, `msglib:<source_table>` refs, `message_source_count`, message-source table/role/row-count summaries, `receive_display_smoke=true`, populated-field booleans, and no entity values, message bodies, file path values, or raw rows.
C38 provider verification additionally prints `message_list_count`, `message_list_sources`, `message_list_body_values_returned=false`, `message_list_raw_rows_returned=false`, `message_list_file_paths_returned=false`, `message_list_content_values_omitted=true`, and `message_list_download_refs_omitted=true`.

View File

@@ -0,0 +1,34 @@
# Business Goals Smoke - 2026-07-10
> 2026-07-12 校准A-route 已合并到 `main``isphere_send_message`
> 在显式 `uia_rpa` 配置下可以执行 UI 写入/点击;但业务送达仍未验证。
> 当前主状态见 `docs/reports/2026-07-12-main-business-status-correction.md`。
## 业务目标状态
| 目标 | 当前状态 | 说明 |
| --- | --- | --- |
| 搜索联系人 | 可用 | MCP smoke 证明配置化 fixture 下可返回联系人候选。 |
| 搜索群组 | 可用 | MCP smoke 证明配置化 fixture 下可返回群组候选。 |
| 收消息 | 基础可用 | PacketReader/log-backed 默认路线和显式 MsgLib 路线已存在R12 增加对账辅助但未改变默认路由。 |
| 发消息 | preview 可用,生产阻断 | isphere_send_message 可规划发送并做审计/idempotency两个 returned sent-record 包仍未给出机器可验证 sent-record。 |
| 收文件 | list 可用download 阻断 | isphere_receive_files 可列文件download preview 返回机器可读阻断原因;真实下载缺少 cache mapping 和 copy gate。 |
| 发文件 | preview 可用,生产阻断 | isphere_send_file 可做 allowed-dir 文件 SHA256/size preview真实上传缺少返回包/上传连接器证据。 |
## Smoke 输出摘要
```json
{"search_contacts_ready":true,"search_groups_ready":true,"receive_messages_ready":true,"send_message_preview_ready":true,"send_message_production_ready":false,"receive_files_list_ready":true,"receive_files_download_ready":false,"receive_files_download_preview_blocked":true,"send_file_preview_ready":true,"send_file_production_ready":false}
```
## 剩余业务阻断
- 发消息生产:缺少严格机器 sent-record/content-hash after 证据。
- 收文件下载:缺少真实 cache mapping 和 copy gate。
- 发文件生产:缺少 upload connector 和 returned file-send evidence。
## 结论
数字员工现在可使用:搜索联系人、搜索群组、收消息基础读取、收文件列表、发消息 preview、发文件 preview。
数字员工还不能使用:生产发消息、真实下载文件、生产发文件。

View File

@@ -0,0 +1,112 @@
# R6f-R14 Release Candidate Status - 2026-07-10
> 2026-07-12 校准:本报告是 A-route 前的 R14 状态。后续 `main`
> 已具备显式配置下的发消息 UI 写入/点击能力,但在线业务送达仍未验证。
> 当前主状态见 `docs/reports/2026-07-12-main-business-status-correction.md`。
本报告是 R6f-R14 连续轮次的业务验收材料。结论先讲清楚:数字员工现在可以用搜索、收消息基础读取、收文件列表,以及发消息/发文件 preview生产发消息、真实下载文件、生产发文件仍然需要在线可登录环境返回证据。
## Business capability status
| 业务能力 | 状态分类 | 当前结论 | 证据/说明 |
| --- | --- | --- | --- |
| 搜索联系人 | 已完成:可直接给数字员工用 | 可用 | `isphere_search_contacts` 已注册;支持 PacketReader/log-backed 候选和可选 MsgLib display enrichmentR13 smoke 通过。 |
| 搜索群组 | 已完成:可直接给数字员工用 | 可用 | `isphere_search_groups` 已注册;支持 groupchat/conference 候选、排序、去重和可选 MsgLib enrichmentR13 smoke 通过。 |
| 收消息 | 已完成:可直接给数字员工用 | 基础读取可用 | 默认 PacketReader/log-backed显式 `source_preference="msglib_readonly"` 可走只读 MsgLibR12 已有对账 helper但默认路由未切换。 |
| 收文件列表 | 已完成:可直接给数字员工用 | 可列文件元数据 | `isphere_receive_files` list mode 可用;返回文件元数据候选,不读取文件内容。 |
| 发消息 preview | 可预览:数字员工可规划但不会真实发出/下载 | 可规划,不会发送 | `isphere_send_message` 支持 target/content hash/idempotency/audit preview生产发送默认 blocked。 |
| 发文件 preview | 可预览:数字员工可规划但不会真实发出/下载 | 可规划,不会上传 | `isphere_send_file` 支持 allowed-dir 文件 SHA256/size/idempotency preview生产上传默认 blocked。 |
| 发消息生产 | 阻断:需要在线可登录环境返回证据 | 未开放 | 两个 returned sent-record 包只有人工成功观察,缺少机器可验证 after sent-record/content-hash 证据,且 after 仍有 offline blocker。 |
| 收文件下载 | 阻断:需要在线可登录环境返回证据 | 未开放 | R10 证明 resolver 评分逻辑R11 提供 download preview blocked/planned contract缺少真实 cache mapping 和 copy gate。 |
| 发文件生产 | 阻断:需要在线可登录环境返回证据 | 未开放 | R7/R8/R9 已完成 preview/idempotency/audit/package缺少上传 connector 和 returned file-send evidence。 |
## MCP tools available to digital employees
当前 Go MCP surface 共 10 个工具:
1. `win_helper_version`
2. `win_helper_self_check`
3. `win_helper_scan_windows`
4. `win_helper_dump_uia`
5. `isphere_receive_messages`
6. `isphere_search_contacts`
7. `isphere_search_groups`
8. `isphere_receive_files`
9. `isphere_send_message`
10. `isphere_send_file`
业务上可直接使用的是联系人搜索、群组搜索、收消息基础读取、收文件列表。`isphere_send_message``isphere_send_file` 目前只能用于 preview/dry-run 规划;不会真实发送、不会上传、不会点击客户端。
## Packages generated under runs
这些包位于 ignored `runs\` 目录不提交到仓库R14 本地已重建或复核相关包。
| 包路径 | 用途 | R14 状态 |
| --- | --- | --- |
| `runs\send-sandbox-gate-package.zip` | 在线/内网环境录制发消息严格证据;用于判断能否开放生产发消息 | 已重建并通过 `verify-send-sandbox-gate-package.ps1`。 |
| `runs\send-file-sandbox-gate-package.zip` | 在线/内网环境录制发文件/上传证据;用于判断能否开放生产发文件 | 已重建并通过 `verify-send-file-sandbox-gate-package.ps1`。 |
| `runs\send-capability-test-package.zip` | 离线能力测试包;验证 10-tool surface、收/搜/列表 fixture、发送 preview 和生产阻断 | 已存在;用于 operator 侧无登录能力测试。 |
| `runs\live-probe-recorder-package.zip` | 早期 live probe/recorder 包 | 保留为历史证据采集材料R14 主包以上面两个 gate package 为准。 |
## Production blockers
1. **发消息生产阻断**:缺少严格机器 sent-record/content-hash after 证据。当前两个 `send-sent-record-diagnostic` 返回包显示人工观察成功,但 `exact_content_seen_after=false``content_sha256_seen_after=false``after_offline_blocked=true`,所以不能打开生产发送门。
2. **收文件下载阻断**:缺少真实 iSphere cache/archive 到消息文件记录的可信映射,以及真实 copy gate。当前只证明 fixture resolver 逻辑,不证明生产文件可复制。
3. **发文件生产阻断**:缺少上传 connector、真实 file-send returned package、上传/发送确认和重复发送保护证据。
## Exact next evidence request if blocked
如果要继续推进真实能力,下一步只需要三类在线证据:
1. **发消息生产**:在可登录环境重新执行 `runs\send-sandbox-gate-package.zip`。返回包必须包含 before/after recorder、人工点击/成功时间、`success_ack_or_sent_record_present=true`、after 记录中能机器验证 exact content 或 content SHA256并确认没有第二次重复发送。
2. **收文件下载**:在可登录环境提供一组真实收文件记录和对应 cache/archive 根目录的 sanitized mapping 输出。验收条件是 `accepted_matches>0``ambiguous_matches=0`,并且后续 copy gate 仍保持不返回原始本地路径和不读取文件内容到报告。
3. **发文件生产**:在可登录环境执行 `runs\send-file-sandbox-gate-package.zip`。返回包必须包含 before/after recorder、文件 SHA256、人工上传/发送确认、上传或 sent-record 证据,并确认没有第二次重复发送。
## Verification commands and latest results
R14 本轮已执行并通过:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-sandbox-gate.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-sandbox-gate-package.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-file-sandbox-gate.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-file-sandbox-gate-package.ps1
git diff --check
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-business-goals-smoke.ps1
go test ./...
go build ./cmd/isphere-mcp
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
```
Latest machine-readable highlights:
```json
{
"business_goals_smoke": true,
"search_contacts_ready": true,
"search_groups_ready": true,
"receive_messages_ready": true,
"send_message_preview_ready": true,
"send_message_production_ready": false,
"receive_files_list_ready": true,
"receive_files_download_ready": false,
"receive_file_download_preview_blocked": true,
"send_file_preview_ready": true,
"send_file_production_ready": false,
"go_mcp_verify": true,
"tool_count": 10,
"production_send_enabled": false,
"send_file_production_enabled": false
}
```
## Business conclusion
从业务角度,四个目标拆开看:
- 搜索联系人:完成。
- 搜索群组:完成。
- 收发消息:收消息基础完成;发消息 preview 完成;生产发消息阻断。
- 收发文件:收文件列表完成;发文件 preview 完成;真实下载和生产发文件阻断。
因此当前不是“全破解完成”,而是“可读/可搜/可规划已经可交付;真实写入和真实下载进入在线证据门禁”。

View File

@@ -0,0 +1,34 @@
# Main Business Status Correction - 2026-07-12
本报告校准 `main` 合并 A-route 后的业务状态避免把“UI 输入/点击”误写成“业务送达”。
## 四个目标当前状态
| 目标 | 当前业务状态 | 已有能力 | 剩余卡点 |
| --- | --- | --- | --- |
| 搜索联系人 | 基础可用 | `isphere_search_contacts` 支持 log-backed 候选和可选 MsgLib enrichment。 | 更丰富字段、成员层级属于后续增强。 |
| 搜索群组 | 基础可用 | `isphere_search_groups` 支持 groupchat/conference 候选、排序、去重和可选 MsgLib enrichment。 | 群成员/管理员层级属于后续增强。 |
| 收发消息 | 收消息基础可用;发消息 UI action ready在线业务送达未验证 | `isphere_receive_messages` 可从 PacketReader/log-backed 或显式 MsgLib readonly 读取;`isphere_send_message` preview 稳定A-route `uia_rpa` 可对真实/合成聊天窗口写入 `rtbSendMessage` 并点击 `btnSend`。 | 需要在线登录环境证明 server ack、接收方送达或 sent-record/content-hash after 证据。 |
| 收发文件 | 收文件 list 可用;发文件 preview 可用;真实下载/真实上传未完成 | `isphere_receive_files` 可列文件元数据并给出 download preview blocked/planned`isphere_send_file` 可做 allowed-dir SHA256/size preview。 | 真实下载需要 cache mapping/copy gate真实发文件需要文件按钮/菜单定位、上传 connector 和在线发送证据。 |
## A-route 合并后的精确定义
- 路线定位B-route/运行客户端 sidecar/API connector 仍是主线A-route/RPA 只是备选兜底,不作为默认产品架构。
- 已实现:打开真实离线 `frmP2PChat`,识别 `rtbSendMessage``btnSend``rtbRecvMessage`
- 已实现:`uia_send_message` 可以写入发送框并触发发送按钮。
- 未证明:在线登录态下消息被服务端接受、对方收到、或本地 sent-record 产生。
- 未实现:真实文件上传。真实离线 UIA dump 没有稳定暴露 `btnSendFile` automation id。
## 后续计划
1. 主线优先:继续验证 B-route running-client sidecar / in-process connector / existing API 的真实发送和文件能力。
2. 备选保持:把真实 `frmP2PChat` HWND 接入 `isphere_send_message``uia_rpa` connector形成可重复 UI action smoke但只作为 B-route 卡住时的兜底。
3. 在线消息验证:在可登录环境采集 before/after sent-record、content hash、ack/成功提示、idempotency 不重复发送证据。
4. 文件发送节点:优先找 B-route 上传/发送 connector如果继续卡住再单独定位真实工具栏/菜单里的文件按钮,做 RPA 上传动作和在线 evidence package。
5. 更新业务 smoke`send_message_ui_action_ready``send_message_business_delivery_ready` 拆成两个字段。
## 对外口径
可以说:数字员工已经具备联系人/群组搜索、收消息基础读取、收文件列表、发消息 preview、发消息 UI 操作能力。
不要说:数字员工已经能稳定在线发送消息或发送文件。当前还缺在线业务证据。

View File

@@ -0,0 +1,111 @@
# N12-pre zyl Evidence Index
Date: 2026-07-09
Evidence id: `zyl-qqfile-20260709`
Archive: `runs/offline-evidence-intake/zyl-qqfile-20260709/archives/zyl.rar`
SHA256: `126CC72F21293829FED374321A2AFE6E067959B63A76981BCB6678C64E887173`
Archive type: RAR5
Archive listing summary: 3931 files, 439 folders
Source metadata: `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/archive-list.txt`
## Current conclusion
The N12-pre package contains a strong local-data candidate for Stage C read tools:
1. `MsgLib.db` files under `zyl\iSphere\...\Account\...` and `zyl\Impp\Account\...`.
2. `importal\localcache` communication logs: `Smark.SendReceive`, `SaveToDB`, `PacketReader.ProcessPacket`, and `LoginInfo`.
3. `importal\<hash>` document/cache directories that can later be mapped to message DB or log references for file receive/download.
The first Stage C source candidate should be the latest visible iSphere DB for account `90005981`, with IMPP DB and communication logs as comparison/fallback evidence.
## Candidate counts from archive list
| Candidate family | Count / summary | Evidence examples |
| --- | --- | --- |
| `MsgLib.db` total | 14 files | 12 under `zyl\iSphere\...\Account\...`, 2 under `zyl\Impp\Account\...` |
| SQLite runtime files | 4 files | `System.Data.SQLite.dll` and `System.Data.SQLite.xml` under both `zyl\iSphere` and `zyl\Impp` |
| `LoginInfo` logs | 18 log files | account/session context |
| `PacketReader.ProcessPacket` logs | 19 log files | packet/message processing trace |
| `SaveToDB` logs | 15 log files | DB persistence trace |
| `Smark.SendReceive` logs | 24 log files | send/receive transport trace; largest observed file is 10,486,426 bytes |
| Hashed `zyl\importal\<hash>` file-cache entries | 11 files plus hashed folders | likely document/file receive cache candidates |
## Candidate source families
| Family | Evidence path examples | Why it matters | Initial action |
| --- | --- | --- | --- |
| SQLite runtime | `zyl\iSphere\System.Data.SQLite.dll`; `zyl\Impp\System.Data.SQLite.dll` | Confirms bundled SQLite usage around message libraries | record versions and inspect dependent DB files |
| iSphere message DB | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005981\MsgLib.db`; `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\duyanming0731\MsgLib.db` | Strong candidate for `isphere_receive_messages`, contacts, groups, and attachment references | extract selected DB copies and inspect schema |
| IMPP message DB | `zyl\Impp\Account\90005981\MsgLib.db`; `zyl\Impp\Account\27091282\MsgLib.db` | Older or parallel candidate source for message history | compare schema with iSphere DB |
| Local communication logs | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Smark.SendReceive\2026-07-07_1.log`; `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\SaveToDB\2026-07-07_1.log`; `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\PacketReader.ProcessPacket\2026-07-07_1.log` | Candidate execution trace for message receive/save behavior | inspect log structure and timestamp alignment |
| Login/info logs | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\LoginInfo\2026-07-07_1.log` | Candidate account/session context for source mapping | inspect field names and account identifiers |
| Received/document cache | `zyl\importal\256914c029af44bd96665abd69ddd500\*.xlsx`; `zyl\importal\4a33b65a33c14149b440724bfc3d912e\g.docx`; `zyl\importal\4fa0846abdbb4331a956cb056be9566a\*.pdf` | Candidate file receive/download cache for `isphere_receive_files` | map hashed directories to DB/log references |
## `MsgLib.db` candidates
| Priority | Modified time | Size bytes | Archive path | Notes |
| --- | --- | ---: | --- | --- |
| 1 | 2026-07-07 15:41:45 | 2,839,552 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005981\MsgLib.db` | latest visible iSphere DB and matches same-day logs |
| 2 | 2022-04-24 08:52:20 | 2,776,064 | `zyl\Impp\Account\90005981\MsgLib.db` | parallel/older DB for same account id |
| 3 | 2022-01-25 09:51:17 | 12,198,912 | `zyl\Impp\Account\27091282\MsgLib.db` | largest IMPP message DB, useful for schema comparison |
| 4 | 2026-05-15 17:29:01 | 346,112 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\duyanming0731\MsgLib.db` | recent named-account DB for schema comparison |
| 5 | 2026-06-22 14:03:56 | 333,824 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\mayanlong1912\MsgLib.db` | recent named-account DB for schema comparison |
| 6 | 2026-06-25 10:59:33 | 69,632 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\xuguopeng101X\MsgLib.db` | recent named-account DB for small-schema comparison |
| 7 | 2025-06-20 17:37:32 | 239,616 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\zhangzhenghu2730\MsgLib.db` | message DB candidate |
| 8 | 2025-10-23 15:10:42 | 204,800 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\maxiaojie7418\MsgLib.db` | message DB candidate |
| 9 | 2024-01-27 19:17:55 | 206,848 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\dumei0466\MsgLib.db` | message DB candidate |
| 10 | 2025-10-10 17:14:12 | 108,544 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90016309\MsgLib.db` | numeric-account DB candidate |
| 11 | 2025-10-28 09:49:12 | 75,776 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90017948\MsgLib.db` | numeric-account DB candidate |
| 12 | 2023-03-31 15:09:48 | 394,240 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90003887\MsgLib.db` | older numeric-account DB candidate |
| 13 | 2022-06-14 14:54:19 | 282,624 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005184\MsgLib.db` | older numeric-account DB candidate |
| 14 | 2025-06-18 14:07:17 | 191,488 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\lixing0316\MsgLib.db` | message DB candidate |
## Same-day account `90005981` log candidates
| Log family | Modified time | Size bytes | Archive path | Why it matters |
| --- | --- | ---: | --- | --- |
| `LoginInfo` | 2026-07-07 15:30:05 | 750 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\LoginInfo\2026-07-07_1.log` | account/session context for the latest DB |
| `SaveToDB` | 2026-07-07 15:30:09 | 9,704 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\SaveToDB\2026-07-07_1.log` | trace of persistence into local DB |
| `PacketReader.ProcessPacket` | 2026-07-07 15:41:45 | 244,606 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\PacketReader.ProcessPacket\2026-07-07_1.log` | packet processing trace near DB timestamp |
| `Smark.SendReceive` | 2026-07-07 15:43:08 | 827,884 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Smark.SendReceive\2026-07-07_1.log` | send/receive trace near DB timestamp |
| `Default` | 2026-07-07 15:33:47 | 98,056 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Default\2026-07-07_1.log` | general client runtime context |
| `Default/ErrorFaltal` | 2026-07-07 15:32:51 | 57,408 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Default\ErrorFaltal\2026-07-07_1.log` | error/runtime context for the same date |
## Received/document cache candidates
The archive contains hashed `zyl\importal\<hash>` directories with document-like files. The visible examples include:
| Archive path pattern | Observed file type | MCP relevance |
| --- | --- | --- |
| `zyl\importal\256914c029af44bd96665abd69ddd500\*.xlsx` | spreadsheet | candidate received-file cache |
| `zyl\importal\4a33b65a33c14149b440724bfc3d912e\g.docx` | Word document | candidate received-file cache |
| `zyl\importal\4fa0846abdbb4331a956cb056be9566a\*.pdf` | PDF | candidate received-file cache |
| `zyl\importal\69003b4a95b5461fbe6fba38de74bc62\*.doc` | Word document | candidate received-file cache |
| `zyl\importal\c4f11655e8d24e96b3a0eb2e3727bba0\*.docx` | Word document | candidate received-file cache |
| `zyl\importal\cd0cd63034824bbaa81e1136da9dc236\*.pdf` | PDF | candidate received-file cache |
These hashed directories become useful after the DB/log inspection identifies message attachment or file-cache references.
## First extraction target list
| Priority | Archive path | Destination under runs | Reason |
| --- | --- | --- | --- |
| 1 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005981\MsgLib.db` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-isphere-90005981/` | latest visible iSphere message DB candidate |
| 2 | `zyl\Impp\Account\90005981\MsgLib.db` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-impp-90005981/` | compare older or parallel IMPP DB candidate |
| 3 | `zyl\Impp\Account\27091282\MsgLib.db` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-impp-27091282/` | largest observed message DB for schema comparison |
| 4 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Smark.SendReceive\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-smark-90005981-20260707/` | message send/receive trace candidate |
| 5 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\SaveToDB\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-savetodb-90005981-20260707/` | DB write trace candidate |
| 6 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\PacketReader.ProcessPacket\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-packetreader-90005981-20260707/` | packet processing trace candidate |
| 7 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\LoginInfo\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-logininfo-90005981-20260707/` | account/session context candidate |
## Stage B2 questions
1. What are the table names and column names inside `MsgLib.db`?
2. Which tables contain message body, sender, receiver/conversation id, timestamp, and attachment/file references?
3. Are contact and group display names stored in the same DB or only inferable from conversation/message rows?
4. Do `SaveToDB` and `Smark.SendReceive` logs reveal DB table mapping or message ids that can be matched to `MsgLib.db`?
5. Which DB/log field maps to the `docs/mcp-core-tools-contract.md` output for `isphere_receive_messages`?
## Decision after index
Proceed to `docs/source-discovery/capability-source-matrix.md` after the first extraction target list has confirmed message DB and log candidates. The likely first Stage C implementation slice is `isphere_receive_messages` backed by `zyl\iSphere\...\Account\90005981\MsgLib.db`, subject to schema inspection.

View File

@@ -0,0 +1,127 @@
# N12-pre zyl Schema and Log Inspection Notes
Date: 2026-07-09
Evidence id: `zyl-qqfile-20260709`
Stage: B2 selected DB/log inspection
## Files inspected under `runs/`
| Label | Extracted path | Result |
| --- | --- | --- |
| iSphere latest DB | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-isphere-90005981/MsgLib.db` | high-entropy binary; not direct SQLite |
| IMPP same-account DB | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-impp-90005981/MsgLib.db` | high-entropy binary; not direct SQLite |
| IMPP large DB | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-impp-27091282/MsgLib.db` | high-entropy binary; not direct SQLite |
| `Smark.SendReceive` log | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-smark-90005981-20260707/2026-07-07_1.log` | decryptable line-based log |
| `SaveToDB` log | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-savetodb-90005981-20260707/2026-07-07_1.log` | decryptable line-based log |
| `PacketReader.ProcessPacket` log | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-packetreader-90005981-20260707/2026-07-07_1.log` | decryptable line-based XMPP message log |
| `LoginInfo` log | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-logininfo-90005981-20260707/2026-07-07_1.log` | decryptable login metadata XML |
## `MsgLib.db` inspection result
The three selected `MsgLib.db` files are not directly readable with Python `sqlite3` read-only mode.
Observed result:
```text
DatabaseError('file is not a database')
```
Observed binary traits:
| File | Size bytes | SHA256 | Header / format signal |
| --- | ---: | --- | --- |
| `msgdb-isphere-90005981/MsgLib.db` | 2,839,552 | `4f308a26e4d528175853dac8889b69f371b4cad779f75ce2e31d2f83043f86a2` | high entropy, first bytes start `45 1b bd 2b`; not `SQLite format 3` |
| `msgdb-impp-90005981/MsgLib.db` | 2,776,064 | `6707d06126e3cd9a535f6d3879123e31377f0cb7d350b7a6e0cdfe1b34656943` | high entropy, first bytes start `45 1b bd 2b`; not `SQLite format 3` |
| `msgdb-impp-27091282/MsgLib.db` | 12,198,912 | `fe60ddcfbaa70523b21ec080f6d7e633298c9a9d710b7e9c36d91996bf4dc0f0` | high entropy, first bytes start `45 1b bd 2b`; not `SQLite format 3` |
Tried the known `IMPP.Util.Log.LogSecurityPolicy` DES/CBC key against the first DB blocks. It did not reveal a SQLite header. Current conclusion: `MsgLib.db` remains an important source candidate, but it is not the first implementable Stage C source until the DB wrapper/encryption/loading path is identified.
## Log decryption result
The selected logs are Base64 line-based ciphertext. The existing local iSphere log policy finding applies to these logs:
- policy owner: `IMPP.Util.Log.LogSecurityPolicy`
- cipher: DES
- mode: CBC
- padding: PKCS7
- key string: `hyhccdtm`
- IV bytes: `12 34 56 78 90 AB CD EF`
- plaintext encoding: UTF-8
Decryption succeeded for all selected log lines:
| Log | Lines | Decrypted | Useful structure |
| --- | ---: | ---: | --- |
| `SaveToDB` | 14 | 14 | NLog-style records from `IMPP.Client.control.MessageCenter.Chat_OnMessageArrived`; message type `com.vision.smack.packet.ReceiptMessage`; includes `from:` and `body:` fields |
| `Smark.SendReceive` | 1,078 | 1,078 | XMPP send/receive stanzas; includes IQ and message traffic |
| `PacketReader.ProcessPacket` | 245 | 245 | XMPP `<message>` stanzas; includes `id`, `from`, `to`, `type`, `<body>`, `<subject>`, `<isphere ... sendtime=...>` |
| `LoginInfo` | 1 | 1 | XML login metadata; contains account/user/dept/contact fields and should be treated as sensitive metadata |
## Field mapping evidence
`PacketReader.ProcessPacket` is now the strongest immediate source for `isphere_receive_messages` because decrypted lines expose message-level XML shape.
Observed redacted shape:
```xml
<message id="..." from="<jid>/imp_pc_4.1.2.6842" to="<jid>" type="chat">
<body>...</body>
<received xmlns="urn:xmpp:receipts" id="..." type="1" stamp="" />
<subject>...</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="..." version="...">
<alert type="0" />
</isphere>
</message>
```
Candidate mapping to `isphere_receive_messages`:
| MCP field | Candidate source |
| --- | --- |
| `message_id` | `<message id="...">` |
| `conversation_id` | normalized `from` / `to` JID pair or group JID |
| `conversation_type` | `<message type="chat|groupchat|...">` and JID shape |
| `sender` | `from` JID, stripped of resource such as `/imp_pc_4.1.2.6842` |
| `timestamp` | log timestamp line plus `<isphere sendtime="...">` when present |
| `content_text` | `<body>...</body>` |
| attachment/file hint | `<subject>` plus body filename when file-transfer subject is present |
| receipt/status | `<received>`, `<consumed>`, `<readed>` elements when present |
`SaveToDB` is useful as a behavioral trace but not the first parser source. It records `Chat_OnMessageArrived` and `ReceiptMessage` entries with `from:` and `body:` fields, and indicates some messages meet a “not stored to small DB” condition. This explains why `MsgLib.db` may not contain every received message even after its wrapper is solved.
`Smark.SendReceive` is useful as a lower-level transport trace. It includes XMPP IQ/message traffic and can become a fallback source or reconciliation source after the `PacketReader` parser is stable.
`LoginInfo` can map the local logged-in account identity, but it contains sensitive user metadata. Use only field names and identifiers needed for normalization; do not include raw personal values in committed docs or test fixtures.
## C12 file-transfer evidence precheck
A temporary ignored scanner decrypted only the selected N12-pre log files and wrote count-only metadata to `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c12-file-evidence-counts.json`. No raw decrypted message content is committed.
Count summary:
| Source | Decrypted | File-transfer / file evidence | Cache/download evidence |
| --- | ---: | --- | --- |
| `PacketReader.ProcessPacket` | 245/245 | 57 `<subject>` hits, 123 `FILE_TRANSFER` hits, 57 `FILE_TRANSFER_CANCEL` hits, 91 body file-extension hits, 107 parsed file-candidate messages | 0 `zyl\importal\<hash>` hits, 0 download-keyword hits |
| `Smark.SendReceive` | 1,078/1,078 | 5 `FILE_TRANSFER` hits, 4 body file-extension hits, 5 parsed file-candidate messages | 0 `zyl\importal\<hash>` hits, 0 download-keyword hits |
| `SaveToDB` | 14/14 | 9 generic file-extension hits and 2 attachment-keyword hits; no parseable XMPP message candidates | 0 `zyl\importal\<hash>` hits, 0 download-keyword hits |
| `archive-list.txt` | n/a | 11 cached file entries under 11 `zyl\importal\<hash>` directories; extensions observed: `.doc`, `.docx`, `.jpg`, `.pdf`, `.xlsx` | cache entries exist, but no confirmed link from message log to cache hash yet |
Decision: file metadata listing can proceed from log-backed normalized messages. Real `download_ref`, `saved_path`, and cache hash resolution remain deferred until the message-to-cache mapping or a client-side download connector is validated.
## Updated source decision
The previous matrix selected `MsgLib.db` as the first source subject to schema inspection. Stage B2 changes that decision:
1. `MsgLib.db` remains a high-value candidate but is blocked by a non-SQLite wrapper/encryption layer.
2. `PacketReader.ProcessPacket` decrypted XMPP XML is the first implementable source for `isphere_receive_messages`.
3. `Smark.SendReceive` and `SaveToDB` are fallback/reconciliation sources.
4. File receiving likely needs `PacketReader` file-transfer XML plus a later map to `zyl\importal\<hash>` document cache directories.
## Next node
Open Stage C with a log-backed source abstraction first:
1. Add a small decrypt/parser package for `IMPP.Util.Log.LogSecurityPolicy` style lines.
2. Parse decrypted `PacketReader.ProcessPacket` XML into normalized message structs.
3. Use redacted fixture strings in tests, not raw user message text.
4. Keep `MsgLib.db` work as a separate follow-up node for DB wrapper/encryption analysis.

View File

@@ -0,0 +1,148 @@
# Bounded DB-backed receive-message source design
Date: 2026-07-10
## Goal
Design the smallest safe implementation slice for using copied/operator-local `MsgLib.db` as a future source for `isphere_receive_messages`.
This is a design node only. It does not add row-reading code and does not change the MCP tool surface.
## Inputs
Evidence now available:
- C35 mapped `tblPersonMsg`, `tblMsgGroupPersonMsg`, `TD_SystemMessageRecord`, receipt, file-record, and recent-index tables to the receive-message contract.
- C36 added metadata-only `message_sources`, proving candidate table availability, required/present columns, roles, and row counts without exposing message bodies, file path values, or raw rows.
- Current MCP `isphere_receive_messages` is PacketReader-log backed and already supports `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, safe `source_preference`, and empty `cursor`.
## Non-goals
C37 and the next implementation slice must not:
- Add send, download, mark-read, login, hook, injection, or DB mutation behavior.
- Return raw DB rows.
- Return local `FilePath` values from file-record tables.
- Replace PacketReader as the only receive source.
- Claim production ingestion completeness.
- Print copied-DB message bodies in evidence-backed smoke output.
## Proposed sidecar operation: `list_messages`
Add a future sidecar operation named `list_messages` under protocol `isphere.msglib.v1`.
### Required args
- `sqlite_dll_path`
- `db_path`
### Optional args
- `password`: default `123`.
- `conversation_id`: exact normalized conversation id or DB conversation key.
- `conversation_type`: `direct`, `group`, `system`, or empty/`auto`.
- `query`: bounded keyword filter over allowlisted columns only.
- `since`: ISO-8601/RFC3339 timestamp.
- `limit`: default `20`, clamp to `1..50` for the first DB row-listing slice.
- `cursor`: must be empty in the first slice.
- `include_body`: default `false` in sidecar verification; MCP integration may explicitly set this later after redacted/synthetic tests pass.
- `include_attachment_metadata`: default `true`; returns only safe attachment fields.
### Returned top-level data
- `read_only=true`
- `raw_rows_returned=false`
- `file_paths_returned=false`
- `message_body_values_returned=<include_body>`
- `source_tables`: unique allowlisted tables used.
- `messages`: normalized bounded message objects.
- `next_cursor=null` in the first slice.
### Normalized message fields
| Field | Required behavior |
| --- | --- |
| `message_id` / `id` | From table primary key: `tblPersonMsg.Id`, `tblMsgGroupPersonMsg.Id`, or `TD_SystemMessageRecord.MsgGuid`. |
| `conversation_id` | Direct: prefer `ObjPersonId`, fallback `SndPersonId|RecvPersonId`; Group: `MsgGroupId`; System: stable `system` or sender-derived key. |
| `conversation_type` | `direct`, `group`, or `system`. |
| `sender_id` | Direct/group: `SndPersonId`; System: `SendPersonJid`. |
| `sender_name` | Direct/group: `SndPerson`; System: `SendPersonName`; later can be overwritten/enriched by display-entity maps. |
| `receiver_id` | Direct: `RecvPersonId`; Group: `MsgGroupId`; System: null or stable system receiver. |
| `text` / `content_text` | Only when `include_body=true`; select `MsgText` first, fallback `MessageBody`; system messages use `MsgText`. |
| `content_type` | Derived from `MsgType` and safe attachment metadata; unknown when mapping is unclear. |
| `subject` | System: `MsgTitle`; direct/group: null until a DB column equivalent is proven. |
| `timestamp` / `created_at` | Direct: `ActionTime`; Group: `MsgTime`; System: `MsgTime`; parse into RFC3339 where possible. |
| `read` | Direct/group/system read-state columns mapped to boolean-like value when possible. |
| `receipt_id` | Null in first slice unless a safe receipt id mapping is proven; DB receipt state may remain in internal metadata only. |
| `attachments` | Join `TD_ReceiveFileRecord` by message id; include `file_id`, `file_name`, `size_bytes`, and null `download_ref`; never return `FilePath`. |
| `source` | `local_readonly`. |
| `raw_ref` | A stable non-row-dump ref such as `msglib:tblPersonMsg`, `msglib:tblMsgGroupPersonMsg`, or `msglib:TD_SystemMessageRecord`. |
## Query and ordering policy
First DB-backed row-listing slice should:
1. Read only allowlisted columns.
2. Filter source tables by `conversation_type` before querying rows when supplied.
3. Filter `conversation_id` against the normalized candidate conversation key.
4. Filter `since` on `ActionTime`, `MsgTime`, or system `MsgTime`.
5. Filter `query` only over allowlisted identity/title/body columns; if `include_body=false`, query may still use body columns internally but must not print the values.
6. Sort descending by timestamp, then descending by primary key/id for deterministic output.
7. Clamp `limit` to at most 50.
8. Reject non-empty `cursor` until pagination is explicitly implemented.
## Body-output policy
The first sidecar implementation may support `include_body`, but verification must separate two paths:
1. **Default/evidence-backed real copied DB smoke:** `include_body=false`; output summaries only, no message body values.
2. **Synthetic/redacted fixture test path:** `include_body=true` may be tested with redacted fixture values only.
Before MCP integration sets `include_body=true` against operator-local data, acceptance must prove:
- strict limit is enforced;
- output contains only normalized message objects, not raw rows;
- file path values are always omitted;
- audit/source fields make the DB source explicit;
- user/operator local data remains uncommitted.
## Attachment policy
Attachment metadata can use `TD_ReceiveFileRecord` only as a safe list source:
- Join key: `ReceiveMsgGuid` equals normalized message id.
- Return allowed: `ReceiveMsgGuid` as file/message ref, `FileName`, `FileSize`, `SourceID`, `SourceName`, `FileType`, `SendPersonJid`, `SendPersonName`, `SendTime` only if needed for normalized metadata.
- Never return: `FilePath`.
- `download_ref` remains null until cache/download mapping is separately validated.
## Relationship with PacketReader source
PacketReader remains the default receive source until DB-vs-log reconciliation is tested. DB-backed receive should enter in stages:
1. C38: sidecar-level bounded `list_messages` operation, with real copied DB smoke using `include_body=false` only.
2. C39: Go wrapper/fake-sidecar tests and possibly synthetic/redacted fixture flow for `include_body=true`.
3. C40: optional Go `ReceiveMessagesSource` implementation behind explicit env/source configuration.
4. Later: decide `auto` merge/reconciliation policy between PacketReader and MsgLib.
The first MCP integration should not silently merge PacketReader and MsgLib rows. It should be explicit through env config or a source-mode decision recorded in docs.
## C38 recommended implementation route
Implement sidecar `list_messages` first, not MCP integration.
C38 acceptance gate:
- Add Go fake-sidecar tests for `ListMessages(ctx, options)` request shape and decoded normalized fields.
- Add sidecar op `list_messages` with allowlisted table/column queries.
- Default sidecar smoke still returns no DB provider checks unless env is configured.
- Evidence-backed copied DB smoke calls `list_messages` with `include_body=false` and prints only:
- `message_list_count`
- `message_list_sources`
- `message_body_values_returned=false`
- `raw_rows_returned=false`
- `file_paths_returned=false`
- No real copied-DB `MsgText`, `MessageBody`, `FilePath`, raw rows, entity values, sends, downloads, hooks, injection, or DB writes.
## Decision
Proceed to C38 with sidecar-level `list_messages` plus Go wrapper tests and sanitized verification. Do not wire DB-backed rows into `isphere_receive_messages` MCP until C38 proves the bounded sidecar behavior.

View File

@@ -0,0 +1,38 @@
# Contact/group display-name source precheck
Date: 2026-07-10
## Question
Can the current N12-pre evidence safely enrich `isphere_search_contacts` and `isphere_search_groups` beyond JID-derived display names?
## Evidence checked
- `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md`
- `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
- Ignored metadata summaries under `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/`
No raw DB/log content or decrypted personal values are committed in this note.
## Findings
1. The committed index identifies `MsgLib.db` as the strongest candidate for messages, contacts, groups, and attachment references, but it leaves display-name storage as an open question.
2. The schema summary for the extracted `MsgLib.db` candidates reports `file is not a database` for the inspected copies, so there is no validated SQLite table/column source for contact names, group names, department, title, or member count yet.
3. `PacketReader.ProcessPacket` gives reliable JIDs and `chat`/`groupchat` routing data, but the current committed field mapping only proves message id, conversation id/type, sender/receiver JIDs, timestamp, content body, subject, and receipt/status fields.
4. `LoginInfo` has sensitive local account/user/dept/contact metadata, but the available summary only proves local account context. It does not prove a full roster or group display-name mapping for arbitrary contacts/groups.
5. `SaveToDB` summaries show message-arrival shapes and `from`/`body` presence, but not a committed display-name/group-title mapping.
## Decision
Do not implement display-name enrichment in the current loop.
Keep `isphere_search_contacts` and `isphere_search_groups` honest as JID-derived local-readonly candidates until one of these sources is validated:
- readable/decrypted `MsgLib.db` tables with contact/group display columns;
- decrypted roster/contact/group stanzas with stable JID-to-name mapping;
- bridge/API/local-service evidence exposing roster/group metadata;
- internal-sandbox UIA or live capture evidence that safely maps visible display names to stable JIDs.
## Next slice
Use C24 to precheck `MsgLib.db` format/readability or identify the correct wrapper/decryption path, because that is the most likely route to contact/group display names and richer message metadata.

View File

@@ -0,0 +1,99 @@
# Static .NET DB wrapper analysis for MsgLib.db
Date: 2026-07-10
## Question
Can the copied `MsgLib.db` files be opened through the same .NET wrapper path used by the iSphere/IMPP client, and which tables/fields are likely to unlock contact, group, message, and file metadata?
## Evidence checked
Ignored read-only IL/metadata generated from the copied archive:
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/HYHC.IMPP.DAL.dll.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/Utilities.Lib.DBBase.dll.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/Utilities.Lib.DBModel.dll.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/Utilities.Lib.DBSQLite.dll.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/Utilities.Lib.SQLiteInteraction.dll.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/RepairDatabase.exe.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-il/IMPP.Model.dll.il`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c25-wrapper-il-summary.json`
`IMPP.Model.dll`, `Utilities.Lib.DBBase.dll`, and `Utilities.Lib.SQLiteInteractionBase.dll` were added to the C25 static inspection because the original C24 wrapper candidates reference them directly. No client executable was run, no original DB was modified, and no raw DB bytes/decrypted message content are committed.
## Connection/password flow findings
1. `HYHC.IMPP.DAL.IMPPDAL` is the client DAL entry point for `MsgLib.db`. Its private constructor accepts `dbPath` and `userInfoDomain`, creates a `DBConfigElement`, and configures:
- `DBID = 0x1a0a`;
- `DBConInfoType = 1`;
- `DatabaseName = dbPath`;
- `Password = "123"`;
- `ConName = Path.GetFileName(dbPath)`;
- `DBFactory = "Utilities.Lib.DBSQLite.Core.SQLiteDBFactory,Utilities.Lib.DBSQLite"`.
2. The same constructor registers the config through `ConfigManager.AddConfigItem(...)`, obtains `_dbAccess` through `DBAccessManager.GetDBAccessInstance(DBID)`, and checks the DB with `IsDBDamage(dbPath)` when the file exists.
3. `Utilities.Lib.DBBase.Base.DBInteractionBase.GetDBConStr(...)` is the central connection-string switch:
- if `DBConfigElement.Decryption` is empty and `DBConInfoType == 0`, it uses `ConStr` directly;
- if `DBConfigElement.Decryption` is empty and `DBConInfoType != 0`, it calls `GenerateDBConStr(config, visitType)`;
- if `Decryption` is set, it instantiates an `IConStrDecryption` implementation and delegates `GetDBConStr(config, visitType)` to it.
4. `Utilities.Lib.SQLiteInteraction.Interaction.GenerateDBConStr(...)` constructs a `System.Data.SQLite.SQLiteConnectionStringBuilder` and, for the DAL path above, sets:
- `Pooling = true`;
- `DataSource = GetFullPath(DatabaseName)`;
- `Password = DBConfigElement.Password` when non-empty;
- `ReadOnly = true`;
- then returns the builder `ConnectionString`.
5. `Utilities.Lib.SQLiteInteraction.Interaction.GetConnection(...)` creates `System.Data.SQLite.SQLiteConnection(connectionString)`.
6. `Utilities.Lib.DBSQLite.Core.SQLiteInteraction.CreateReadConnection(...)` calls `GetDBConStr(config, DBVisitType)` and then `GetConnection(...)`.
## Candidate table/model findings
The static IL gives enough table and model names to target schema-only DB extraction next.
### Message and conversation tables
- `tblMsgGroupPersonMsg` is the main group/discussion message table. Static SQL references include create/insert/update/select paths for:
- `Id`, `SndPerson`, `SndPersonId`, `MsgType`, `MsgText`, `MsgFont`, `MsgTime`;
- `MsgGroupId`, `MsgGroupName`, `SessionTitle`, `SessionPersonId`, `SessionPersonName`, `GroupType`;
- `MsgReadState`, `MsgRecaptionState`, `IsReceipt`, `MessageBody`, `InterOut`, `MsgContent`, `MsgVersion`, `MsgReadStatus`;
- later-added fields such as `ReadMembers`, `UnreadTotal`, and `MemInfoVersion`.
- `tblPersonMsg` is the main one-to-one message table. Static SQL/model fields include sender, receiver, object person, message text/body/content/version/read state, and receipt state fields.
- `tblRecent` likely stores recent conversation entries; model fields include `PersonId` and `PersonName`.
### Contact and display-name sources
- `TD_Roster` is explicitly queried with `JId`, `Nick`, `Ask`, `Subscription`, `Show`, `Status`, and `GroupName`.
- `tblChatLevel` has `PersonId` and `PersonName` fields.
- `tblRecent` has `PersonId` and `PersonName` fields.
- `CustomEffigyModel` includes `PersonJid`, `PersonName`, `PersonSex`, avatar/local path style fields.
### Group and member-display sources
- `tblMsgGroupPersonMsg` provides `MsgGroupId`, `MsgGroupName`, `SessionPersonId`, `SessionPersonName`, `GroupType`, and read/member summary fields.
- `TD_WorkGroupAuth` has create/update/select evidence and model `WorkGroupAuth` contains `GroupJID`, `GroupName`, `SendPersonJid`, `SendPersonName`, and `ChildGroupJid`.
- `GetDiscussMemberNames(groupJid)` queries distinct `SessionPersonId` from `tblMsgGroupPersonMsg` by `MsgGroupId`.
### File metadata candidates
- `tblRecvFile` and the receive/send file models expose `FileName`, file id/path-like fields, `SndPerson`, `SndPersonId`, and sender/receiver display-name fields.
- Group file-message insert/update paths write file-transfer references through `tblMsgGroupPersonMsg`.
## Decision
C25 recovered the wrapper route. The next safe path is no longer blind DB-format guessing.
High-confidence local hypothesis:
> A copied `MsgLib.db` should be attempted through the exact bundled `System.Data.SQLite.dll` path with a read-only connection string equivalent to `Data Source=<copied MsgLib.db>;Password=123;Read Only=True;Pooling=False` or the wrapper-generated equivalent.
C26 can attempt read-only schema extraction from copied DB files under ignored paths. C26 should only query schema/table/column names and row counts first; it must not dump message bodies, personal values, or write to any DB.
If the direct read-only `System.Data.SQLite` open fails, the next fallback is to record the exact exception and use the DAL/wrapper path as the reference for a wrapper-assisted schema-only probe.
## Next slice
Loop C26 should perform copied-DB read-only schema extraction:
1. Use the bundled `System.Data.SQLite.dll` from the extracted iSphere client files.
2. Open only copied DB files under `runs/offline-evidence-intake/.../extracted/msgdb-*`.
3. Use the recovered password `123` and force read-only mode.
4. Query `sqlite_master` and `PRAGMA table_info(...)` for target tables only.
5. Save schema metadata under ignored `runs/.../metadata/` and commit only docs/matrix/status updates.

View File

@@ -0,0 +1,73 @@
# File Cache Mapping Diagnostic
Date: 2026-07-10
## Goal
Run the R3b local-only diagnostic to determine whether copied MsgLib received-file metadata can be mapped to the available ignored cache/archive listing without printing file paths, raw rows, message bodies, or attachment contents.
## Diagnostic command shape
The diagnostic script is:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-file-cache-mapping.ps1 \
-ArchiveListPath <ignored-archive-list>
```
The script can also accept `ISPHERE_MSGLIB_SQLITE_DLL` and `ISPHERE_MSGLIB_DB` from the environment. The committed note intentionally omits local DB, DLL, and archive-list path values.
## Sanitized result
Safe summary from the available copied DB plus ignored archive-list metadata:
```json
{
"ok": true,
"verification": "file_cache_mapping",
"db_checked": true,
"db_table_available": true,
"db_record_count": 166,
"db_filename_present_count": 166,
"db_size_present_count": 166,
"db_source_id_present_count": 166,
"db_receive_msg_guid_present_count": 166,
"cache_checked": true,
"cache_candidate_count": 11,
"hash_dir_length_counts": {"32": 11},
"filename_only_match_count": 0,
"filename_size_match_count": 0,
"unique_filename_size_match_count": 0,
"source_id_hash_dir_match_count": 0,
"mapping_validated": false,
"recommended_next_node": "additional evidence request",
"raw_paths_printed": false,
"file_contents_read": false,
"file_paths_returned": false,
"raw_rows_returned": false,
"message_body_values_printed": false
}
```
Extension counts are allowed as coarse metadata. The diagnostic normalizes abnormal extensions to `.other` so file-name fragments are not printed.
## Decision
Do not implement R4 receive-file download mode from the current evidence.
Reason:
- MsgLib received-file metadata exists and is internally complete enough for metadata analysis: filename, size, source id, and receive message id are present for 166 inspected rows.
- The available cache/archive listing has 11 hash-shape cache candidates.
- The diagnostic found 0 filename-only matches, 0 filename+size matches, and 0 source-id-to-hash-dir matches.
- Therefore no unique or repeatable cache-file resolver can be claimed.
## Next route
File download remains blocked until one of these is available:
1. A better operator-local cache root or fresh archive-list that actually contains the same received files as `TD_ReceiveFileRecord`.
2. A known recent received-file sample where the operator can confirm the message/file metadata and resulting local cache object, with committed output still limited to counts and booleans.
3. A UI/client download connector path that can trigger download without relying on cache guessing.
The broader roadmap should skip R4 for now and continue with R5 send-message connector discovery.

View File

@@ -0,0 +1,124 @@
# File Download Mapping Precheck
Date: 2026-07-10
## Goal
Determine whether `isphere_receive_files` can safely move from list mode to download mode.
This precheck uses only sanitized counts, table/column names, source refs, booleans, extension counts, and path-shape descriptions. It does not commit local file paths, file contents, decrypted message bodies, raw DB rows, DB credentials, or attachment contents.
## Current product behavior
`isphere_receive_files` currently supports list mode only.
Current list source:
- `internal/isphere.ListFilesFromMessages` extracts file candidates from normalized PacketReader messages.
- It returns metadata such as `file_id`, `message_id`, `conversation_id`, `file_name`, `mime_type`, `created_at`, `source`, and `raw_ref`.
- `download_ref`, `saved_path`, and `sha256` remain empty.
- `internal/tools/isphere_files.go` rejects non-list modes and rejects `output_dir` until download mode is implemented.
## Evidence inventory
### PacketReader file-transfer evidence
Safe count evidence from C12:
| Source | Parsed message evidence | Cache/download signal |
| --- | --- | --- |
| `PacketReader.ProcessPacket` | 107 file-candidate messages; 123 `FILE_TRANSFER` hits; extension counts include `.doc`, `.docx`, `.pdf`, `.png`, `.xlsx`, `.zip`. | 0 importal-hash path hits; 0 download-keyword hits. |
| `Smark.SendReceive` | 5 file-candidate messages; extension counts include `.doc`, `.docx`, `.pdf`, `.zip`. | 0 importal-hash path hits; 0 download-keyword hits. |
| `SaveToDB` | 9 generic file-extension hits, but no parseable XMPP message candidates. | 0 importal-hash path hits; 0 download-keyword hits. |
Conclusion: PacketReader is sufficient for a file list, but not sufficient for local file resolution.
### MsgLib file-record evidence
Validated copied-DB table metadata:
- `TD_ReceiveFileRecord`: `ReceiveMsgGuid`, `FileName`, `FileSize`, `FilePath`, `SourceID`, `SourceName`, `FileType`, `SendPersonJid`, `SendPersonName`, `SendTime`.
- `TD_SendFileRecord`: `SendMsgGuid`, `FileName`, `FileSize`, `FilePath`, `SourceID`, `SourceName`, `FileType`, `ReceivePersonJid`, `ReceivePersonName`, `ReceiveTime`.
Current sidecar behavior in `native/MsgLibReadSidecar/Program.cs`:
- Joins `TD_ReceiveFileRecord` by `ReceiveMsgGuid`.
- Returns `file_id`, `file_name`, `size_bytes`, and an empty `download_ref`.
- Uses `SafeFileName(...)` for file names.
- Does not return `FilePath`; smoke output keeps `file_paths_returned=false`.
Conclusion: MsgLib can provide stronger received-file metadata than PacketReader, but it still does not prove a cache-file path or client download operation.
### Cache path-shape evidence
Ignored archive metadata shows:
```json
{
"importal_hash_shape_files": 11,
"hash_dir_length_set": [32],
"extension_counts": {
".doc": 2,
".docx": 3,
".jpg": 2,
".pdf": 2,
".xlsx": 2
},
"raw_paths_printed": false
}
```
Conclusion: local cache files exist under a stable hash-directory shape, but no committed evidence maps a message id, `ReceiveMsgGuid`, `SourceID`, filename, or size to a specific cache file.
## Candidate mappings
| Candidate | Evidence strength | Status |
| --- | --- | --- |
| `PacketReader message_id + body filename` -> file list row | Medium for list mode. | Already implemented for metadata listing. |
| `TD_ReceiveFileRecord.ReceiveMsgGuid` -> MsgLib message id | Strong for DB attachment-to-message relation. | Usable for metadata enrichment after source reconciliation. |
| `TD_ReceiveFileRecord.FileName + FileSize` -> cache file filename/size | Medium if a local diagnostic proves unique matches. | Not validated yet. |
| `TD_ReceiveFileRecord.SourceID` -> cache hash directory or download token | Unknown. | Needs focused diagnostic; do not use as `download_ref` yet. |
| `TD_ReceiveFileRecord.FilePath` -> local file path | Potentially direct but sensitive. | Do not return or commit; may only be used in a local diagnostic that prints counts/booleans, not values. |
| UI/client operation -> trigger download | Unknown. | Later fallback if cache mapping fails. |
## Decision
Do not implement R4 download mode yet.
R3 chooses option 3 from the acceptance gate: **additional evidence request**, concretely a new R3b local-only cache mapping diagnostic.
Reason:
- There is enough evidence to list received files.
- There is enough evidence to identify DB attachment records.
- There is not enough evidence to safely copy or download the file because no validated mapping exists between message/file metadata and a local cache object.
## R3b diagnostic requirements
The next node should add or run a local-only diagnostic that outputs only sanitized evidence:
- Inputs:
- copied `MsgLib.db` and bundled SQLite DLL env already used by MsgLib verification;
- operator-local cache root or ignored archive-list source.
- Allowed output:
- count of `TD_ReceiveFileRecord` rows inspected;
- count of rows with filename, size, source id, and receive message id present;
- cache candidate counts by extension and hash-directory shape;
- match counts by safe strategy: filename-only, filename+size, source-id-shape, message-id-linked;
- booleans: `raw_paths_printed=false`, `file_contents_read=false`, `file_paths_returned=false`.
- Not allowed output:
- local paths;
- raw DB rows;
- real message bodies;
- attachment contents;
- copied file names if they are not explicitly redacted.
## Next implementation node
Next node: **R3b file cache mapping diagnostic**.
Possible R3b outcomes:
1. If filename+size or source-id mapping is unique and repeatable, proceed to R4 cache-file resolver/download implementation.
2. If cache mapping is ambiguous but the client exposes a safe UI/API download action, proceed to a UI/client download connector node.
3. If neither mapping is proven, request additional operator evidence and keep `isphere_receive_files` in list-only mode.

View File

@@ -0,0 +1,49 @@
# File download mapping v2 diagnostic - 2026-07-10
## Purpose
R10 checks whether received-file metadata can be mapped to an operator-local cache/download file strongly enough for a future `isphere_receive_files` download path.
## What changed
`scripts/verify-file-cache-mapping.ps1` now has a fixture-backed scoring mode:
- `-FixtureRecordsPath`: JSON records with `file_name`, `size_bytes`, `source_id`, and `created_at`.
- `-CacheRoot`: operator-local cache root or fixture cache root.
- `-SanitizedOutputPath`: writes a compact JSON summary without raw paths or file contents.
Scoring rules:
| Rule | Score | Accepted |
| --- | ---: | --- |
| exact filename + exact size + close timestamp | 100 | yes, if unique |
| exact size + source id appears in cache name/ref | 80 | yes, if unique |
| filename only | 40 | no |
## Fixture result
`powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-verify-file-cache-mapping.ps1` produced:
```json
{"ok":true,"archive_candidate_count":2,"records_checked":3,"cache_candidates_checked":4,"accepted_matches":2,"ambiguous_matches":0,"score_100_matches":1,"score_80_matches":1,"score_40_only_matches":1,"raw_paths_printed":false,"file_contents_read":false}
```
Interpretation:
- The resolver algorithm can produce accepted matches when file metadata is strong.
- One weak filename-only case is intentionally not accepted.
- The diagnostic output stays committed-safe: no raw operator paths and no file contents.
## Production decision
This round proves the resolver logic, not a real iSphere cache mapping on this machine. The local machine still cannot log in to iSphere, and no real returned receive-file package in this repository state proves a stable cache/download mapping.
Therefore:
- `isphere_receive_files` list mode remains usable.
- Real file download/copy remains blocked until a real cache mapping package produces accepted, non-ambiguous matches.
- R11 should implement a download preview/blocked contract so digital employees get machine-readable status instead of a generic failure.
## Next node
R11: `Receive-file download preview contract`.

View File

@@ -0,0 +1,128 @@
# iSphere Live Probe Recorder
Date: 2026-07-10
Purpose: collect read-only runtime evidence from a machine where iSphere / IMPlatformClient can actually log in.
## Why this recorder exists
The current development machine cannot log in because the current network has no reachable server. Simulating login would only prove window rendering and would not prove the real logged-in runtime state required by the B-route sidecar connector.
Therefore the selected path is to carry a dedicated recorder executable to a working environment, run it after manual login, and bring the generated JSON evidence back.
## Built artifact
Package directory:
```text
runs/live-probe-recorder-package/
```
Executable:
```text
runs/live-probe-recorder-package/ISphereLiveProbeRecorder.exe
```
Zip package:
```text
runs/live-probe-recorder-package.zip
```
Compatibility layout:
```text
ISphereLiveProbeRecorder.exe # top-level x86 build, use this first
variants/ISphereLiveProbeRecorder-x86.exe # same x86 fallback copy
variants/ISphereLiveProbeRecorder-anycpu.exe # AnyCPU fallback
variants/ISphereLiveProbeRecorder-x64.exe # x64 fallback
check-and-run.bat # prints environment and tries the top-level recorder
run-recorder.bat # runs the top-level recorder
run-x86-fallback.bat # runs the x86 fallback
```
The top-level executable is intentionally x86 because it can start on both 32-bit and 64-bit Windows and is more compatible with older enterprise desktop clients.
The package is generated by:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-live-probe-recorder.ps1
```
## Operator steps
1. Copy `runs/live-probe-recorder-package.zip` to the machine/network where iSphere can connect to its server.
2. Extract the zip.
3. Manually open and log in to iSphere / `IMPlatformClient.exe`.
4. Manually open one direct-contact chat window and one group-chat window if possible. Keep the editor/send/attachment area visible, but do not send anything.
5. Keep the client window open.
6. Double-click `ISphereLiveProbeRecorder.exe`.
7. The program writes a folder under:
```text
probe-output/isphere-live-probe-<timestamp>/
```
8. Compress that generated folder and return it.
If Windows shows `System.BadImageFormatException`, delete the extracted folder, re-extract the latest zip package, and run `check-and-run.bat`. The previous package used an AnyCPU top-level executable; the current package uses x86 at the top level and keeps other builds under `variants/`.
## What it records
The recorder writes:
```text
manifest.json
probe_client_runtime.json
process_details.json
network_inventory.json
filesystem_inventory.json
send_entrypoints_preflight.json
send_uia_controls_preflight.json
services_registry_shortcuts.json
scan_windows.json
uia_summary.json
uia/*.json
feasibility_summary.json
README.txt
NEXT_STEP.txt
```
The important evidence is:
- target process PID/name/title/path;
- process bitness;
- loaded module list;
- command-line metadata with basic secret redaction;
- key module presence, including `smack.dll`, `IMPP.Interface.dll`, `IMPP.ServiceBase.dll`, `IMPP.Service.dll`, `TcpFileTransfer.dll`;
- visible window metadata;
- UIA tree for target windows, useful if route A UI/RPA is needed later.
- install/cache/config/log/database candidate metadata;
- version and SHA256 for important binaries;
- send-message and send-file entrypoint metadata preflight for `IMPlatformClient.exe`, `smack.dll`, and `IMPP.Service.dll`;
- UIA send-control classifier output for search/edit/send/file controls and offline-send blocker state;
- redacted text samples for small config/log files;
- uninstall registry entries;
- related services and shortcuts;
- target-process TCP connections;
- related named pipes.
## What it does not do
The recorder does not:
- send messages;
- send or upload files;
- click UI;
- type text;
- inject hooks;
- attach instrumentation;
- modify client files or data.
## Decision after returned evidence
If `probe_client_runtime.json` proves the loaded modules and bitness match the static evidence, continue with B-route sidecar design.
If the running client cannot be inspected, or the expected modules are absent, switch to the A-route constrained UI/RPA fallback.
If `send_entrypoints_preflight.json` reports `b_route_entrypoint_preflight="pass"` and `send_uia_controls_preflight.json` reports the controls/offline state clearly, continue to R6b preview/dry-run contract design. Production send still requires a later online sandbox send gate.

View File

@@ -0,0 +1,116 @@
# MsgLib.db message-table source precheck
Date: 2026-07-10
## Question
Can copied `MsgLib.db` message tables become a future DB-backed source for `isphere_receive_messages`, and what is the safest next implementation slice?
## Evidence used
Committed evidence:
- `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`
- `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
- `docs/msglib-read-sidecar-contract.md`
Ignored local evidence consulted in this loop:
- C26 schema-only x86 JSON under `runs/offline-evidence-intake/.../metadata/`.
Scope of this loop:
- Read table names, column names, schema SQL, and row counts only.
- Do not read or print message row values.
- Do not read or print message bodies, file paths, contact/group values, or raw rows.
- Do not add code that exposes DB message rows yet.
## Candidate DB tables
| Table | Safe evidence | Candidate role | C35 decision |
| --- | --- | --- | --- |
| `tblPersonMsg` | Present in all opened copied DBs; count-only evidence shows thousands of rows in inspected copies | direct/person-to-person messages | strong candidate for future DB-backed direct messages |
| `tblMsgGroupPersonMsg` | Present in all opened copied DBs; count-only evidence shows hundreds/thousands of rows in inspected copies | group/discussion messages | strong candidate for future DB-backed group messages |
| `TD_SystemMessageRecord` | Present with count-only evidence | system/auth/notification messages | secondary candidate after direct/group message flow |
| `TD_GroupReceiptMessage` | Present in richer schema | per-person group receipt state by `MsgGuid`/`PersonID` | useful join candidate for receipt enrichment |
| `TD_ReceiveFileRecord` | Present with message-guid and file metadata columns | received-file metadata linked by message guid | useful for attachment metadata, but `FilePath` must not be exposed by default |
| `TD_SendFileRecord` | Present with message-guid and file metadata columns | sent-file metadata linked by message guid | later outbound/file-source evidence only |
| `tblRecent` | Maintained by triggers from person/group message inserts | recent conversation index | useful for conversation ordering/display, not primary message body source |
## Field mapping hypothesis
### Direct messages: `tblPersonMsg`
| MCP receive field | Candidate DB column | Confidence | Notes |
| --- | --- | --- | --- |
| `message_id` / `id` | `Id` | high | primary key in schema |
| `conversation_type` | constant `direct` | high | table is direct/person message source |
| `conversation_id` | `ObjPersonId`, fallback normalized `SndPersonId|RecvPersonId` | medium | trigger writes `ObjPersonId` into `tblRecent.PersonId`; need row-level validation before finalizing |
| `sender_id` | `SndPersonId` | high | schema names sender identity explicitly |
| `sender_name` | `SndPerson` or optional display-entity map | high | value exposure should follow existing display-name policy |
| `receiver_id` | `RecvPersonId` | high | schema names receiver identity explicitly |
| `text` / `content_text` | `MsgText` or `MessageBody` | medium | body columns exist, but C35 does not read values; future body read needs strict limit/audit contract |
| `content_type` | `MsgType` | medium | values not inspected in C35 |
| `timestamp` / `created_at` | `ActionTime` | high | direct message timestamp column |
| `read` | `MsgReadState` | medium | boolean-like column; semantics need row-level validation |
| `receipt_id` / receipt state | `IsReceipt`, `MsgReceiptState`, `ReceiptTime`, `MsgRecaptionState` | low/medium | schema supports state but not the same as PacketReader receipt id |
| `subject` | none | low | direct DB table does not expose PacketReader `<subject>` equivalent in schema |
| `attachments` | join `TD_ReceiveFileRecord.ReceiveMsgGuid = tblPersonMsg.Id` | medium | `FilePath` is sensitive and should remain hidden unless a separate download/cache contract exists |
### Group messages: `tblMsgGroupPersonMsg`
| MCP receive field | Candidate DB column | Confidence | Notes |
| --- | --- | --- | --- |
| `message_id` / `id` | `Id` | high | primary key in schema |
| `conversation_type` | constant `group` | high | table is group/discussion message source |
| `conversation_id` | `MsgGroupId` | high | group id column and recent trigger key |
| `conversation.display_name` | `MsgGroupName` or `SessionTitle`, plus optional display-entity map | high | use existing MsgLib display source priority where possible |
| `sender_id` | `SndPersonId` | high | schema names sender identity explicitly |
| `sender_name` | `SndPerson` or optional display-entity map | high | value exposure should follow existing display-name policy |
| `receiver_id` | `MsgGroupId` | medium | group id is the receive target in MCP contract terms |
| `text` / `content_text` | `MsgText` or `MessageBody` | medium | body columns exist, but C35 does not read values; future body read needs strict limit/audit contract |
| `content_type` | `MsgType`, possibly `GroupType` for conversation class | medium | values not inspected in C35 |
| `timestamp` / `created_at` | `MsgTime` | high | group message timestamp column |
| `read` | `MsgReadState` | medium | boolean-like column; semantics need row-level validation |
| `receipt_id` / receipt state | `IsReceipt`, `MsgReceiptState`, `MsgRecaptionState`; optional join `TD_GroupReceiptMessage.MsgGuid` | low/medium | DB state exists but PacketReader receipt id equivalence is unproven |
| `subject` | none | low | group DB table does not expose PacketReader `<subject>` equivalent in schema |
| `attachments` | join `TD_ReceiveFileRecord.ReceiveMsgGuid = tblMsgGroupPersonMsg.Id` | medium | hide `FilePath`; list only safe file metadata until cache/download mapping exists |
### System messages: `TD_SystemMessageRecord`
| MCP receive field | Candidate DB column | Confidence | Notes |
| --- | --- | --- | --- |
| `message_id` / `id` | `MsgGuid` | high | primary key in schema |
| `conversation_type` | constant `system` | medium | source is system/auth record table |
| `sender_id` / `sender_name` | `SendPersonJid` / `SendPersonName` | medium | values not inspected in C35 |
| `text` / `content_text` | `MsgText` | medium | body-like column exists; future body read requires a strict contract |
| `subject` | `MsgTitle` | medium | title-like field exists |
| `timestamp` | `MsgTime` | high | system message timestamp column |
| `read` | `MsgReadState` | medium | bit-like field exists |
## Important gaps
1. `MsgText` and `MessageBody` are body columns. C35 confirms only that they exist, not whether their values are safe, decoded, complete, or aligned with PacketReader bodies.
2. `MsgLib.db` may not contain every received message. Earlier SaveToDB evidence noted some messages may not be stored to the small DB, so DB-backed receive should not replace PacketReader as the only source until reconciliation is tested.
3. `subject` is strong in PacketReader XML but not clearly represented in `tblPersonMsg`/`tblMsgGroupPersonMsg` schema.
4. Receipt semantics are not identical yet. DB columns show receipt/read state, but C35 does not prove the PacketReader receipt id mapping.
5. Attachment metadata can likely join through `TD_ReceiveFileRecord`, but `FilePath` is sensitive and should remain hidden by default.
6. Row ordering and incremental pagination need an explicit timestamp/id strategy before DB-backed listing is exposed.
## Decision
`MsgLib.db` is a strong future source for DB-backed `isphere_receive_messages`, but it should not jump directly to body-returning message listing.
Recommended next implementation slice:
1. Add a metadata-only sidecar operation for message-source readiness, for example `message_sources`.
2. Return table availability, required/present columns, count-only summaries, and the committed field-map shape.
3. Wire a Go wrapper and verification script for this metadata-only operation.
4. Keep message body values, raw rows, local file paths, and DB row dumps out of scope until the metadata operation is verified.
## Proposed C36 acceptance gate
- Default sidecar smoke still passes with `self_check` only.
- Evidence-backed sidecar smoke prints only `message_source_count`, table names, required/present columns, and row counts.
- No `MsgText`, `MessageBody`, `FilePath`, entity values, raw rows, sends, downloads, hooks, injection, or DB writes.
- Capability matrix records whether C37 may safely design a bounded DB-backed receive source.

View File

@@ -0,0 +1,85 @@
# MsgLib.db readability and wrapper-path precheck
Date: 2026-07-10
## Question
Why are the copied `MsgLib.db` files not directly readable as SQLite, and what is the safest next path for extracting contact/group display names and richer message metadata?
## Evidence checked
- Existing ignored metadata:
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/schema-inspection-summary.json`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/archive-list.txt`
- New ignored C24 metadata:
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c24-msglib-format-precheck.json`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c24-wrapper-string-precheck.json`
- Read-only extracted wrapper candidates under ignored:
- `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/c24-wrapper-candidates/`
No raw DB bytes, decrypted messages, or personal values are committed in this note.
## Findings
1. The three inspected `MsgLib.db` copies do not start with SQLite magic and contain no SQLite header within the first 1 MiB sample.
2. The inspected DB samples have high entropy and the same non-SQLite prefix shape across iSphere and IMPP account copies. This strongly suggests a consistent encrypted/wrapped DB format rather than a random corrupt copy.
3. The archive contains standard SQLite libraries, DB abstraction libraries, and a repair tool next to `MsgLib.db`:
- `System.Data.SQLite.dll`
- `Utilities.Lib.DBSQLite.dll`
- `Utilities.Lib.SQLiteInteraction.dll`
- `Utilities.Lib.SQLiteInteractionBase.dll`
- `Utilities.Lib.DBBase.dll`
- `Utilities.Lib.DBModel.dll`
- `HYHC.IMPP.DAL.dll`
- `RepairDatabase.exe`
4. String-only static scanning of wrapper candidates shows that `HYHC.IMPP.DAL.dll` and `RepairDatabase.exe` reference message/group DB behavior and candidate table/method names, including:
- `tblMsgGroupPersonMsg`
- `ImportOldDataBase`
- `GetGroupMessaeByID`
- `GetGroupRecentMsg`
- `UpdateGroupName`
- `SelectMsgGroupName`
- `SelectRecentGroupName`
- `SaveGroupInfomation`
- `groupJid`
- `set_MemberJid`
- `set_MsgGroupName`
- `set_MemberName`
- `get_naturalname`
5. `Utilities.Lib.SQLiteInteraction.dll` exposes `SQLiteConnectionStringBuilder` plus `get_Password` and `set_Password`.
6. `Utilities.Lib.DBModel.dll` exposes configuration concepts such as `IConStrDecryption`, `PasswordPropertyName`, and `DecryptionPropertyName`.
7. `smcrypto-*` and `BouncyCastle.Crypto.dll` are present, but C24 does not prove they are the actual `MsgLib.db` wrapper. They are supporting crypto candidates only.
## Decision
Do not treat `MsgLib.db` as ordinary SQLite.
The best current hypothesis is:
> `MsgLib.db` is an encrypted or wrapped SQLite database opened through the client DB abstraction layer, likely involving `Utilities.Lib.SQLiteInteraction*`, `Utilities.Lib.DBModel`, and `HYHC.IMPP.DAL`.
The next safe implementation path is not direct DB reads. It is static wrapper analysis first:
1. Inspect .NET metadata/IL for the DB wrapper assemblies.
2. Identify how connection strings/password/decryption are supplied.
3. Identify table names and model classes for message, contact, group, member, and display-name fields.
4. Only after that, attempt a read-only schema extraction on copied DB files under ignored paths.
## Next slice
C25 should perform a read-only static .NET wrapper analysis over the extracted candidate assemblies. It should produce table/model/source mappings only, not execute the client and not write to the original DB.
Suggested C25 focus:
- `HYHC.IMPP.DAL.dll`
- `Utilities.Lib.SQLiteInteraction.dll`
- `Utilities.Lib.DBModel.dll`
- `Utilities.Lib.DBSQLite.dll`
- `RepairDatabase.exe`
Expected output:
- candidate table names;
- candidate model/property names for contact/group/message metadata;
- likely connection-string/password/decryption flow;
- decision whether C26 can attempt a copied-DB read-only schema extraction.

View File

@@ -0,0 +1,115 @@
# MsgLib.db copied read-only schema extraction
Date: 2026-07-10
## Question
Can copied `MsgLib.db` files be opened read-only with the wrapper password recovered in C25, and which schema fields should drive the next business read implementation?
## Evidence checked
Ignored C26 artifacts:
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c26-schema-probe-x86/`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c26-msglib-schema-probe-x86.json`
Probe method:
- referenced the bundled `System.Data.SQLite.dll` from the extracted iSphere client files;
- opened copied `MsgLib.db` files only;
- used the C25 recovered password `123`;
- forced read-only mode in the SQLite connection string;
- queried only `sqlite_master`, `PRAGMA table_info(...)`, and safe target-table row counts;
- did not dump message bodies, contact values, file paths, or raw rows.
## Runtime finding
- A 64-bit probe failed at CLR/SQLite mixed-mode loading.
- A 32-bit .NET Framework probe succeeded.
- Successful provider: `System.Data.SQLite`, version observed through the opened DB runtime as SQLite `3.6.23.1`.
This means the production path should not assume Go can open this DB directly. The practical route is a dedicated x86 .NET read-only sidecar/helper that Go MCP calls through a bounded JSON contract.
## Open result
All three copied `MsgLib.db` candidates opened successfully with `Password=123` in read-only mode.
| Copied DB | Open result | Table count | Notable safe counts |
| --- | --- | ---: | --- |
| DB A, about 12.2 MB | ok | 13 | `tblMsgGroupPersonMsg=2884`, `tblPersonMsg=2132`, `tblRecent=52`, `TD_SystemMessageRecord=469` |
| DB B, about 2.84 MB | ok | 23 | `tblMsgGroupPersonMsg=1048`, `tblPersonMsg=1209`, `tblRecent=80`, `tblChatLevel=105`, `TD_WorkGroupAuth=2`, `TD_SystemMessageRecord=314` |
| DB C, about 2.78 MB | ok | 13 | `tblMsgGroupPersonMsg=796`, `tblPersonMsg=1932`, `tblRecent=51`, `TD_SystemMessageRecord=228` |
The counts above are schema-validation counts only. No row values were extracted into committed docs.
## Confirmed business schema map
### Contacts and display names
Validated tables/columns:
- `TD_Roster`: `JId`, `Domain`, `Nick`, `Ask`, `Subscription`, `Show`, `Status`, `GroupName`.
- `TD_CustomEffigy`: `PersonJid`, `PersonName`, `PersonSex`, `HeadImageSize`, `LocalPath`, `ServerUpdateTime`.
- `tblRecent`: `Id`, `PersonId`, `PersonName`, `ActionTime`, `FType`.
- `tblChatLevel`: present in the richer DB and already known from C25 model fields as `PersonId`, `PersonName`.
- `tblPersonMsg`: `SndPerson`, `SndPersonId`, `RecvPerson`, `RecvPersonId`, `ObjPerson`, `ObjPersonId`, plus message metadata fields.
Recommended contact display-name priority for a future read source:
1. `TD_Roster.Nick` by `TD_Roster.JId` when present.
2. `TD_CustomEffigy.PersonName` by `PersonJid` when present.
3. `tblRecent.PersonName` by `PersonId` for recent conversations.
4. `tblPersonMsg` sender/receiver/object display fields as fallback.
5. JID-derived fallback when DB display fields are absent.
### Groups and member names
Validated tables/columns:
- `tblMsgGroupPersonMsg`: `MsgGroupId`, `MsgGroupName`, `SessionTitle`, `SessionPersonId`, `SessionPersonName`, `GroupType`, plus message metadata fields.
- `TD_WorkGroupAuth`: `SessionID`, `SessionStatus`, `SessionType`, `ApplyInfo`, `GroupJID`, `GroupName`, `SendPersonSex`, `ApplyDateTime`; richer DB also has `ChildGroupJid`.
- `tblRecent`: stores group recent entries through triggers using `PersonId = MsgGroupId`, `PersonName = MsgGroupName`, and `FType = GroupType`.
Recommended group display/member priority:
1. `tblMsgGroupPersonMsg.MsgGroupName` by `MsgGroupId`.
2. `tblRecent.PersonName` by `PersonId` where `FType` indicates group/discussion.
3. `TD_WorkGroupAuth.GroupName` by `GroupJID` for work-group authorization records.
4. `tblMsgGroupPersonMsg.SessionPersonId/SessionPersonName` for member-name enrichment.
5. JID-derived fallback when DB display fields are absent.
### Receive messages
Validated tables/columns:
- `tblPersonMsg`: one-to-one message table, including ids, sender/receiver/object identities, text/body/content/version/read fields, timestamps, and receipt state.
- `tblMsgGroupPersonMsg`: group/discussion message table, including ids, sender identities, group ids/names, session title/member fields, message body/content/version/read fields, and timestamps.
- `TD_SystemMessageRecord`: system/auth message metadata.
C27 should not read message bodies yet. The next code loop should first add a schema/display-name sidecar and only later add DB-backed message listing with strict limit/redaction controls.
### File metadata
Validated tables/columns:
- `TD_ReceiveFileRecord`: `ReceiveMsgGuid`, `FileName`, `FileSize`, `FilePath`, `SourceID`, `SourceName`, `FileType`, `SendPersonJid`, `SendPersonName`, `SendTime`.
- `TD_SendFileRecord`: `SendMsgGuid`, `FileName`, `FileSize`, `FilePath`, `SourceID`, `SourceName`, `FileType`, `ReceivePersonJid`, `ReceivePersonName`, `ReceiveTime`.
File download remains blocked until cache/download mapping is separately validated.
## Decision
C26 validates the DB path:
> `MsgLib.db` can be opened from copied evidence with the bundled 32-bit `System.Data.SQLite.dll`, `Password=123`, and read-only mode.
The project can now move from log-only read support toward DB-backed contact/group display-name enrichment. Because the working provider path is 32-bit .NET, the next implementation should be a bounded x86 .NET sidecar/helper rather than direct Go SQLite access.
## Next slice
Loop C27 should implement the first production-shaped DB bridge as a narrow helper/sidecar contract, not as broad message extraction:
1. Build a committed contract for a read-only `MsgLib.db` schema/display-name sidecar.
2. Start with safe operations such as `msglib_self_check` and `msglib_list_display_sources` or equivalent.
3. Return only table availability, column availability, and display-name candidate maps with strict limits.
4. Keep message-body reads, file downloads, sends, writes, and DB mutation out of scope.

View File

@@ -0,0 +1,176 @@
# Receive Message Reconciliation Precheck
Date: 2026-07-10
## Goal
Compare PacketReader and MsgLib receive-message sources using sanitized identifiers, counts, source refs, and booleans only.
This is a precheck node. It does not change MCP default routing, does not merge sources, and does not add pagination, send, download, hook, injection, or DB-write behavior.
## Sources
### PacketReader source
Current role: default `isphere_receive_messages` source when `source_preference` is empty, `auto`, or `local_readonly`.
Evidence:
- Decrypted `PacketReader.ProcessPacket` lines expose XMPP `<message>` stanzas.
- Current Go source: `internal/isphere.EncryptedPacketLogSource`.
- Current configuration:
- `ISPHERE_PACKET_LOG_FILE`
- `ISPHERE_PACKET_LOG_DIR`
- Current normalized fields include:
- `message_id`
- `conversation_id`
- `conversation_type`
- `sender_id`
- `receiver_id`
- `content_text`
- `subject`
- `timestamp`
- `receipt_id`
- read flag
### MsgLib DB source
Current role: explicit copied-DB receive source only when `source_preference="msglib_readonly"` and full MsgLib env is configured.
Evidence:
- C26 proved copied `MsgLib.db` opens through 32-bit `System.Data.SQLite` with password `123`.
- C38 added sidecar `list_messages`.
- C39 added `internal/isphere.MsgLibMessageSource`.
- C40 added explicit `msglib_readonly` tool selection.
- C41 wired env-configured MsgLib receive and optional copied-DB MCP smoke.
Current optional smoke prints sanitized evidence only:
```json
{
"db_receive_smoke": true,
"db_receive_message_count": 5,
"db_receive_sources": ["msglib:tblPersonMsg"],
"message_body_values_printed": false,
"file_paths_returned": false,
"raw_rows_returned": false
}
```
## Candidate match keys
Use these match keys for a future reconciliation helper:
```text
strong: message_id exact match when both sources expose the same id
medium: same conversation id, same sender id, timestamp within a small window
medium: same group id, same sender id, same subject/body-present flag
weak: filename plus nearby timestamp for file-transfer messages
not allowed in committed evidence: message body values, local file paths, raw rows
```
Recommended future scoring:
| Score | Meaning | Required sanitized evidence |
| --- | --- | --- |
| strong | Same message id and compatible source refs. | `message_id`, `raw_ref`, source name. |
| medium | Same conversation/sender and close timestamp. | `conversation_id`, `sender_id`, timestamp bucket. |
| medium | Same group/sender and same subject/body-present state. | `conversation_type=group`, group id, sender id, body-present boolean. |
| weak | Same file name/size near the same time. | file name, size, timestamp bucket; no path values. |
| no match | Different ids and no close timestamp/participant overlap. | sanitized ids/booleans only. |
## Known mismatches
1. **Completeness may differ.** PacketReader sees decrypted transport/client message stanzas; MsgLib DB may not store every message.
2. **Subject coverage differs.** PacketReader exposes XML `<subject>` strongly; direct/group MsgLib tables do not have a clearly equivalent subject column.
3. **Receipt semantics differ.** PacketReader receipt ids and MsgLib receipt/read-state columns are not proven equivalent.
4. **Timestamp format differs.** PacketReader uses XMPP/log epoch-like values; MsgLib uses table-specific timestamp columns such as `ActionTime`, `MsgTime`, and system `MsgTime`.
5. **Conversation id shape differs.** PacketReader may use normalized `sender|receiver` for direct messages; MsgLib direct messages may prefer `ObjPersonId`.
6. **Attachment mapping is incomplete.** MsgLib can expose safe attachment metadata through `TD_ReceiveFileRecord`, but `download_ref` and local cache path remain unresolved.
7. **Body output policy differs.** PacketReader configured-source verification uses redacted/synthetic bodies. Real copied-DB optional smoke must not print body values even if the internal explicit DB receive call can detect body presence.
## Safe evidence allowed in logs
Allowed in committed docs or verification output:
- Counts: message count, source count, table count.
- Source refs: `packetlog_message`, `msglib:tblPersonMsg`, `msglib:tblMsgGroupPersonMsg`, `msglib:TD_SystemMessageRecord`.
- Booleans:
- body values present internally
- body values printed externally
- file paths returned
- raw rows returned
- sender/conversation display populated
- Sanitized field names and table names.
- Timestamp bucket or normalized time window when values are not personally revealing.
Not allowed in committed docs or verification output:
- Real message body values.
- Real local file paths.
- Raw DB rows.
- Raw decrypted logs.
- User credentials or login metadata values.
- Attachment contents.
- Production send/download side effects.
## Decision for default routing
Do not change default routing yet.
Current rule remains:
| `source_preference` | Receive source |
| --- | --- |
| empty | PacketReader/log-backed source |
| `auto` | PacketReader/log-backed source |
| `local_readonly` | PacketReader/log-backed source |
| `msglib_readonly` | Explicit MsgLib DB receive source, only when env is configured |
Reason:
- PacketReader is still the safest default because it is the current parsed receive source and was implemented first with redacted fixtures.
- MsgLib DB is now a valid explicit receive source, but it needs reconciliation before it can become part of `auto`.
- The current safe evidence is enough to design a reconciliation helper, but not enough to automatically merge or replace sources.
## Next implementation node
Recommended next business node after R12: continue R13/R14 business smoke and release-candidate closure unless new real evidence changes priority.
Reason:
- Send-message and send-file both depend on reliable target resolution.
- Search results need deterministic ranking and de-duplication before write-side connector work.
- Receive-source reconciliation can continue later as a helper node before any `auto` merge, but it should not block target search hardening.
Future receive-specific node after R2/R3:
- Add a local-only reconciliation helper that compares PacketReader and MsgLib messages by sanitized keys and outputs only match counts, source refs, and mismatch categories.
- Do not implement pagination until reconciliation proves which source should drive ordering.
- Do not implement file download until R3 proves message-to-cache/download mapping.
## R12 implementation result
R12 added a local-only reconciliation helper:
- `internal/isphere/message_reconciliation.go`
- `internal/isphere/message_reconciliation_test.go`
- `scripts/verify-receive-source-reconciliation.ps1`
Fixture verification:
```json
{"ok":true,"verification":"receive_source_reconciliation","use_fixture":true,"strong_matches":1,"medium_matches":1,"unmatched_left":1,"unmatched_right":1,"auto_merge_recommended":false,"exact_fixture_auto_merge_recommended":true,"raw_message_bodies_returned":false,"raw_rows_returned":false,"file_paths_returned":false,"default_receive_routing_changed":false}
```
The helper is pure comparison logic: it performs no DB reads, log reads, send, download, hook, injection, or writes.
Current match behavior:
- strong match: exact non-empty `message_id` on both sides;
- medium match: same `conversation_id`, same `sender_id`, and timestamp within 3000 ms;
- auto merge is recommended only for complete strong-match coverage with no unmatched or medium-only records.
Decision remains unchanged: default `isphere_receive_messages` routing stays PacketReader/log-backed. `msglib_readonly` remains explicit only.

View File

@@ -0,0 +1,173 @@
# Returned Live Probe Analysis
Date: 2026-07-10
Node: R5b / C30 runtime evidence intake
Evidence package: `runs/returned-live-probe/isphere-live-probe-20260710-121958/`
Zip SHA256: `73815D5D5370D646410C95A008EB1EAAC0C189DADEF1A9ECF69C3D628A156FEE`
Recorder: `ISphereLiveProbeRecorder` `0.2.0`, `isphere.helper.v1`, x86 process on x64 Windows
## Goal
Decide whether the returned logged-in-client probe unblocks the send-message/send-file connector path.
The probe is read-only evidence. It does not send, click, type, upload, download, dump memory, or hook the client.
## Runtime evidence
| Field | Observed value |
| --- | --- |
| Target process | `IMPlatformClient.exe` |
| PID count | `1` |
| Main executable | `C:\Program Files (x86)\Impp\IMPlatformClient.exe` |
| Client version | `4.1.2.6842` |
| Process bitness | x86 |
| UI framework evidence | WinForms/WPF/.NET Framework 4 plus CEF modules |
| Loaded install modules | `IMPlatformClient.exe`, `libcef.dll`, `chrome_elf.dll`, `System.Data.SQLite.dll` |
| Live TCP connection | `10.212.229.210:<ephemeral>` -> `25.215.214.81:10088`, `ESTABLISHED` |
| Config endpoint evidence | `ConnSel.xml` contains `25.215.214.81:10083` |
| Registry uninstall entry | `即时通讯 4.1.2.6839`, uninstall path under install directory |
Interpretation:
- B-route sidecar work must be 32-bit / x86-compatible.
- The live client is a .NET Framework desktop process with CEF and SQLite loaded.
- The observed version matches the previously extracted send-candidate binary family: `IMPlatformClient.exe` length `8366592`, version `4.1.2.6842`.
- The live process did not currently load `IMPP.*`, `smack.dll`, `TcpFileTransfer.dll`, `INetwork.dll`, or `IOClientNetwork.dll` as enumerated process modules. This does not reject B route, because those assemblies are present on disk and may be loaded lazily or as managed assemblies not visible through this module view.
## Installed assembly evidence
The live install directory contains the B-route candidate files:
| Candidate | Present in live install | Version/size evidence |
| --- | --- | --- |
| `IMPlatformClient.exe` | yes | version `4.1.2.6842`, length `8366592` |
| `smack.dll` | yes | version `1.0.0.0`, length `370688` |
| `IMPP.Interface.dll` | yes | version `1.0.0.0`, length `33792` |
| `IMPP.ServiceBase.dll` | yes | version `1.0.0.0`, length `12800` |
| `IMPP.Service.dll` | yes | version `1.0.0.0`, length `21504` |
| `IMPP.Common.dll` | yes | version `1.0.0.0`, length `479744` |
| `IMPP.Model.dll` | yes | version `1.0.0.0`, length `35328` |
| `IMPP.UI.dll` | yes | version `14.4.13.0`, length `2401792` |
| `IMPP.Util.dll` | yes | version `1.0.0.0`, length `10240` |
| `TcpFileTransfer.dll` | yes | length `45056` |
| `HYHC.IMPP.DAL.dll` | yes | version `1.0.0.0`, length `148992` |
| `INetwork.dll` | yes | length `87552` |
| `IOClientNetwork.dll` | yes | length `145920` |
Interpretation:
- Gate B2 "files present / loadable from same install family" is partially satisfied.
- Gate B2 "runtime entrypoints reachable from logged-in client context" is not yet satisfied.
- Next B-route probe should be `probe_send_entrypoints`: read-only reflection/metadata confirmation for `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and file-upload/file-message methods.
## Local data evidence
The inventory found active local state under the iSphere document profile path shape:
```text
%USERPROFILE%\Documents\iSphere\<profile>\27000000\Account\<account>\MsgLib.db
```
The latest active `MsgLib.db` candidate was updated on 2026-07-10 UTC and belongs to the same account family previously used for copied-DB sidecar verification.
Interpretation:
- The receive/search side remains well-supported by the existing `MsgLibReadSidecar` path.
- This package does not include DB contents or table output, so it is not a new receive-message schema proof by itself.
## UIA evidence
The probe captured three `IMPlatformClient.exe` windows successfully:
| Window type | Root automation id | Evidence |
| --- | --- | --- |
| Main window | `frmMain` | WinForms tree captured; left search edit is addressable |
| Group chat | `frmReserveChatRoom` | receive document, send document, send button, file menu captured |
| Direct/P2P chat | `frmP2PChat` | receive document, send document, send button, file menu captured |
Important controls:
| Capability | UIA evidence |
| --- | --- |
| Search contacts/groups | main window edit `skinAlphaTxt`, prompt `按帐号、姓名、拼音、标签查询联系人`; toolbar button `查找联系人、工作组` |
| Read chat panel | chat document `rtbRecvMessage` |
| Draft text | chat document `rtbSendMessage` under `ucChatSendMessageBox1` |
| Send text | button `btnSend`, name `发送(S)` |
| Send file | toolbar/menu item `发送文件`; child items include `发送文件`, `最近使用文件`, `最近使用文件夹`, and group-window `查看文件列表` |
| Conversation member info | group pane shows member-count control shape |
Critical blocker observed in both chat windows:
```text
您已处于离线状态,无法发送消息,请上线后再次尝试!
```
Interpretation:
- A-route fallback is now much stronger than before: it can locate search, draft, send button, and file-send menu controls.
- A-route is not yet production-safe because the captured state explicitly reports an offline send blocker.
- The first A-route implementation slice should be non-mutating only: locate controls, report offline/disabled send state, and optionally support draft preview in a controlled sandbox later. It should not click `btnSend` yet.
## Network/protocol evidence
The runtime had one established TCP connection:
```text
remote=25.215.214.81:10088
```
The install config also points at:
```text
25.215.214.81:10083
```
Interpretation:
- Network/protocol route has endpoint evidence, but still lacks a sent-message request/ack contract.
- Network replay/reimplementation remains deferred behind B route and A fallback.
## Connector decision
This returned package changes the write-side status from "no live connector evidence" to "B/A connector evidence found, but production send remains blocked".
### B route status
- B1 runtime discovery: pass.
- B2 file presence/load-family check: partial pass.
- B2 runtime entrypoint availability: not yet proven.
- B3 non-mutating dynamic observation: not yet done.
- B4 dry run: not yet implemented.
- B5 one sandbox send: not yet allowed by evidence.
### A route status
- Main search selector: found.
- Chat editor selector: found.
- Send button selector: found.
- File-send menu selector: found.
- Failure/offline state: found.
- Production send: blocked until online/test-target evidence and a dry-run/idempotency/audit path exist.
## Business-goal impact
| Business goal | Impact from this package |
| --- | --- |
| Search contacts | Live UIA search box exists; existing MCP search remains DB/log-backed and already usable. |
| Search groups | Live UIA search/group toolbar exists; existing MCP search remains DB/log-backed and already usable. |
| Receive messages | Active `MsgLib.db` path shape confirmed; existing explicit `msglib_readonly` receive path remains the implementation route. |
| Send messages | Connector evidence improved, but actual sending is still not complete. Next work is non-mutating B entrypoint probe and/or A offline-state preflight. |
| Receive files | File-list metadata remains supported; download mapping is still blocked. |
| Send files | File menu and DLLs exist, but send-file remains behind message-send connector plus upload/file-transfer dry-run evidence. |
## Next node
Recommended next node: **R6a / C31 non-mutating send connector preflight**.
Scope:
1. Add/read-only `probe_send_entrypoints` evidence using installed/extracted binaries.
2. Confirm `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and file-upload/file-message entrypoints by metadata, not by invoking them.
3. Add UIA preflight classification for search/edit/send/file controls plus offline-send message detection.
4. Keep `isphere_send_message` production blocked.
5. Update the plan only after the preflight says whether B can continue or A fallback should take over.

View File

@@ -0,0 +1,67 @@
# Returned Send Sandbox Analysis V2
Date: 2026-07-10
## Intake source
Checked latest zip files under `C:\Users\zhaoy\Downloads`.
No new strict-v2 returned send-sandbox package was found during this round.
The only current send-return candidate remains:
```text
C:\Users\zhaoy\Downloads\1631.zip
```
## Validation command
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\validate-returned-send-sandbox-package.ps1 -ZipPath C:\Users\zhaoy\Downloads\1631.zip -StrictV2
```
## Sanitized validation result
```json
{
"partial_evidence_usable": true,
"r6d_gate_pass": false,
"strict_v2_pass": false,
"capability_test_ok": true,
"capability_tool_count": 9,
"production_send_enabled": false,
"has_before_probe": true,
"has_after_probe": false,
"operator_started_at_local_present": false,
"operator_clicked_send_at_local_present": false,
"operator_observed_success_at_local_present": false,
"before_recorder_present_flag": false,
"after_recorder_present_flag": false,
"success_ack_or_sent_record_present_flag": false,
"duplicate_second_send_attempted_false": false,
"decision": "partial_use_only"
}
```
## Missing for strict-v2 full gate
- `manual_send_started_at_local`
- `manual_send_finished_at_local`
- `after_recorder_output`
- `operator_started_at_local`
- `operator_clicked_send_at_local`
- `operator_observed_success_at_local`
- `before_recorder_present`
- `after_recorder_present`
- `success_ack_or_sent_record_present`
- `duplicate_second_send_attempted=false`
## Decision
`r6d_gate_pass=false` and `strict_v2_pass=false`.
Production `isphere_send_message` remains blocked. Continue with local-safe rounds. A future full production-send gate requires a returned package generated from the R6j strict-v2 package.
## Next implementation node
R6l must keep default production send blocked and make the blocked state machine-readable through the send gate behavior already introduced in R6h.

View File

@@ -0,0 +1,133 @@
# Returned Send Sandbox Analysis
Date: 2026-07-10
Node: R6d returned sandbox evidence intake
Returned package: `C:\Users\zhaoy\Downloads\1631.zip`
Local intake copy: `runs/returned-send-sandbox/1631/`
## Decision
Use this return package as **partial R6d evidence** only.
Business interpretation:
- It is enough to continue the next engineering loop with a sandbox-only send connector shell, idempotency/audit hardening, and stricter returned-package validation.
- It is not enough to enable production `isphere_send_message`.
- It is not enough to start `isphere_send_file` production work.
Repository behavior remains:
```text
production_send_enabled=false
```
## Validator
Repeatable validation command:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\validate-returned-send-sandbox-package.ps1 -PackagePath C:\Users\zhaoy\Downloads\1631.zip
```
Fixture test:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-validate-returned-send-sandbox-package.ps1
```
Observed validator result:
```json
{
"partial_evidence_usable": true,
"r6d_gate_pass": false,
"capability_test_ok": true,
"capability_tool_count": 9,
"production_send_enabled": false,
"content_hash_matches": true,
"operator_observed_success": true,
"do_not_send_second_time_confirmed": true,
"has_before_probe": true,
"has_after_probe": false,
"missing_for_full_gate": [
"manual_send_started_at_local",
"manual_send_finished_at_local",
"after_recorder_output"
],
"decision": "partial_use_only"
}
```
## Evidence received
The returned zip contains:
```text
isphere-live-probe-20260710-162555.zip
RUN-RECORDING-SUITE.txt
SANDBOX-SEND-INPUTS..json
send-capability-test-package.txt
```
Notes:
- `SANDBOX-SEND-INPUTS..json` has a double dot in the filename, but the validator accepts this pattern.
- The target id is present but should stay redacted in committed docs.
- The raw message body is not needed in committed docs; the validator checks the supplied SHA256 against the local returned JSON.
- The capability-test transcript proves the offline-safe 9-tool package ran successfully and kept production send disabled.
## Evidence that is usable now
Usable for next engineering loop:
- explicit sandbox target fields are present;
- content SHA256 matches the supplied test content;
- idempotency key is present;
- operator recorded observed success/sent-record text;
- operator confirmed no second send;
- one read-only live probe package was returned;
- capability smoke passed with 9 tools and `production_send_enabled=false`.
This is enough to justify moving from "waiting for any returned send evidence" to:
1. returned-package validator hardening;
2. sandbox-only connector shell design;
3. idempotency/audit persistence design;
4. fake/sandbox connector tests.
## Missing for full R6d pass
Missing:
- `manual_send_started_at_local`;
- `manual_send_finished_at_local`;
- after-send recorder output;
- standard `return-evidence/before` and `return-evidence/after` shape from `make-return-zip.ps1`.
Because these are missing, the full gate does **not** pass.
## Current route decision
Proceed with "partial use" only:
```text
R6d partial_use_only -> R6e sandbox-only send connector shell and idempotency audit hardening
```
Do not change the current MCP production-send response:
```text
execution_mode="production" -> send_status="blocked"
```
## What would make it full pass later
Ask the operator to return a corrected package only when convenient:
1. run `02-run-after-recorder.bat`;
2. fill `manual_send_started_at_local`;
3. fill `manual_send_finished_at_local`;
4. run `03-make-return-zip.bat`;
5. return the generated `isphere-send-sandbox-return-*.zip`.
The current partial package can still be used for the next local development loop.

View File

@@ -0,0 +1,55 @@
# Returned send-sent record diagnostic intake - 2026-07-10
## Inputs reviewed
- `send-sent-record-diagnostic-package1(1).zip`
- `send-sent-record-diagnostic-package2(1).zip`
The raw packages stay outside the repository. This note records only safe summary fields.
## Validator
Added `scripts/validate-send-sent-record-diagnostic-package.ps1` for this package family. It outputs booleans and counts only; it does not print raw target id, raw message body, raw idempotency key, or local operator paths.
## Safe validation summaries
Package 1:
```json
{"ok":true,"package_type":"send-sent-record-diagnostic","package_file_name":"send-sent-record-diagnostic-package1(1).zip","summary_json_present":false,"input_json_present":true,"content_sha256_matches_input":true,"before_run_count":1,"after_run_count":1,"exact_content_seen_after":false,"content_sha256_seen_after":false,"after_offline_blocked":true,"operator_observed_success":true,"do_not_send_second_time_confirmed":true,"production_send_enabled":false,"automatic_send_enabled":false,"strict_send_record_pass":false,"production_send_unlock_recommended":false,"decision":"manual_success_without_machine_sent_record"}
```
Package 2:
```json
{"ok":true,"package_type":"send-sent-record-diagnostic","package_file_name":"send-sent-record-diagnostic-package2(1).zip","summary_json_present":true,"input_json_present":true,"content_sha256_matches_input":true,"before_run_count":1,"after_run_count":1,"exact_content_seen_after":false,"content_sha256_seen_after":false,"after_offline_blocked":true,"latest_after_route_hints":["A_ROUTE_INCOMPLETE","A_ROUTE_OFFLINE_BLOCKED"],"operator_observed_success":true,"do_not_send_second_time_confirmed":true,"production_send_enabled":false,"automatic_send_enabled":false,"strict_send_record_pass":false,"production_send_unlock_recommended":false,"decision":"manual_success_without_machine_sent_record"}
```
Strict mode was also checked against package 2 and failed, as expected, because `strict_send_record_pass=false`.
## Business interpretation
The two packages improve confidence that a human operator successfully sent the fixed sandbox message once. They do not prove that the bridge can safely enable production send, because the machine-verifiable sent-record evidence is still missing:
- `exact_content_seen_after=false`
- `content_sha256_seen_after=false`
- `after_offline_blocked=true`
- package 2 after route hints include `A_ROUTE_INCOMPLETE` and `A_ROUTE_OFFLINE_BLOCKED`
## Decision
Production `isphere_send_message` remains blocked. Current usable state remains:
- send-message preview/dry-run: usable
- audit and idempotency: usable
- production send: blocked by missing strict machine sent-record evidence
## Next requirement for unlocking production send
A future online package must show all of the following without printing raw body/target/path values:
1. before and after recorder outputs are present;
2. content hash or sent-record marker is visible after send;
3. offline blocker is absent after send;
4. operator confirms only one send was performed;
5. strict validator returns `strict_send_record_pass=true` and `production_send_unlock_recommended=true`.

View File

@@ -0,0 +1,139 @@
# Send Connector Preflight
Date: 2026-07-10
Node: R6a / C31 non-mutating send connector preflight
## Goal
Confirm, without sending/clicking/typing/uploading/hooking/capturing network traffic, whether the selected B-route send connector can continue and whether the A-route UI fallback can classify the necessary controls and offline blocker state.
## Implemented helper coverage
`ISphereWinHelper` is bumped to `0.4.0` and adds two read-only helper operations:
| op | Purpose | Side effects |
| --- | --- | --- |
| `probe_send_entrypoints` | Inspect copied/installed binaries for text/file send entrypoint metadata. | none |
| `probe_send_uia_controls` | Classify UIA controls for search, receive pane, send editor, send button, file menu, and offline blocker. | none |
Both operations return a `safety` object with these booleans set to `false`: `sent_message`, `sent_file`, `uploaded_file`, `clicked_ui`, `typed_text`, `captured_network`, `attached_hook`, and `modified_client_data`.
The live recorder now writes these additional files when run in an online iSphere environment:
```text
send_entrypoints_preflight.json
send_uia_controls_preflight.json
```
## B-route entrypoint preflight result
Manual metadata probe against the copied install family:
```text
runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/c28-send-candidates/zyl/Impp
```
reported:
```json
{
"entrypoint_count": 7,
"available_count": 7,
"required_count": 5,
"required_available_count": 5,
"all_required_available": true,
"b_route_entrypoint_preflight": "pass"
}
```
Confirmed candidates:
| Capability | Candidate | Evidence |
| --- | --- | --- |
| text send | `AppContextManager.SendTxtMessageByJid` | method name and `AppContextManager` type-name metadata present in `IMPlatformClient.exe`; IL evidence in the C28 snapshot shows the class/method body |
| text send | `MessageScheduling.SendChatMessage` | reflection method found in `IMPlatformClient.exe` |
| text send | `com.vision.smack.Chat.SendMessage` | reflection method found in `smack.dll` |
| file send/upload | `IMPP.Service.BLL.FileTransfer.FileUpload` | reflection method found in `IMPP.Service.dll` |
| file send/upload | `IMPP.Service.BLL.FileTransfer.AsynFileUpload` | reflection method found in `IMPP.Service.dll` |
| file send | `IMPP.Client.OffLineFileSend.sendFile` | reflection method found in `IMPlatformClient.exe` |
| file send | `com.vision.smack.Chat.sendFileMessage` | reflection method found in `smack.dll` |
Note: `AppContextManager.SendTxtMessageByJid` is treated as metadata-confirmed because .NET reflection did not resolve that type in the helper process, while the IL snapshot contains the exact class and method and the binary string scan finds the method and short type name. This is enough for R6a preflight, not enough for production invocation.
## A-route UIA preflight result
The helper classifier detects these route facts from a UIA tree:
- `has_search_edit`
- `has_receive_document`
- `has_send_editor`
- `has_send_button`
- `has_file_menu`
- `offline_blocker_visible`
- `send_button_enabled`
- `route_hint`
Synthetic WinForms verification returned:
```json
{
"synthetic_send_uia_route_hint": "A_ROUTE_OFFLINE_BLOCKED"
}
```
R5b returned live-probe evidence already showed real-window selector candidates for `skinAlphaTxt`, `rtbSendMessage`, `btnSend`, and the file-send menu, plus the offline-send blocker text. The new helper op turns that evidence into repeatable classifier output for the next live package.
## Verification
Passing local verification:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-win-helper.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1 -SkipBuild
```
Observed helper smoke output:
```json
{
"ok": true,
"helper_version": "0.4.0",
"send_entrypoint_probe_mode": "read_only_send_entrypoint_metadata",
"synthetic_send_uia_route_hint": "A_ROUTE_OFFLINE_BLOCKED",
"uia_available": true
}
```
## Decision
R6a passes as a non-mutating preflight:
- B-route entrypoint metadata is available enough to continue to the next non-production B-route step.
- A-route controls can be classified as a fallback and can explicitly report offline-blocked state.
- Production `isphere_send_message` and `isphere_send_file` remain blocked.
## Next implementation node
R6b B-route preview/dry-run contract is complete. `isphere_send_message` now plans sends, validates content SHA256 and idempotency key, writes redacted audit metadata, and blocks production.
Next node: **R6c online sandbox-send evidence gate**.
R6c should collect or prepare the one approved sandbox-send evidence package needed before production can be exposed. It must still prove success/ack or sent-record evidence and duplicate idempotency protection before any production send path is enabled.
## R6i B-route adapter shell update
R6i adds a Go-side connector adapter shell only:
- `BRouteSendAdapterConfig`
- `NewBRouteSendMessageConnector`
- `Mode="disabled"`
- `Mode="dry_run_contract"`
The shell does not start a sidecar process, does not load DLLs, does not click/type UI, does not send network traffic, and does not upload files.
Current behavior:
- `disabled` returns `ErrorCode="broute_mode_blocked"` and a blocked connector result.
- `dry_run_contract` validates the request shape and returns `ErrorCode="broute_dry_run_only"` with `ConnectorMode="broute-dry-run-contract"`.
Decision: B-route now has a stable Go connector boundary for later sidecar work, but production `isphere_send_message` remains blocked until the strict online sandbox-send evidence gate passes and a validated sidecar exists.

View File

@@ -0,0 +1,68 @@
# 2026-07-10 R9 Send-file sandbox evidence gate
## Decision
Local development still cannot log in to iSphere/IMPlatformClient, so real file upload must not be enabled from local-only evidence.
R9 prepares a one-pass online/internal sandbox package for collecting the missing evidence. The package is intentionally operator-driven:
- it does not click, type, upload, hook, inject, or send automatically;
- the operator manually sends exactly one approved sandbox file;
- before/after read-only recorder output and completed operator fields are returned for validation;
- production `isphere_send_file` remains blocked until the returned package passes validation.
## Package
Builder:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-file-sandbox-gate.ps1
```
Verifier:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-file-sandbox-gate-package.ps1
```
Expected generated zip:
```text
runs\send-file-sandbox-gate-package.zip
```
## Returned-package validator
Use this after an online/internal sandbox operator returns a zip:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\validate-returned-send-file-sandbox-package.ps1 -ZipPath <returned-zip> -Strict
```
The validator checks only sanitized booleans/counts/metadata:
- target fields are present;
- `ISPHERE_SEND_FILE_ALLOWED_DIR` is recorded;
- `sandbox_file_path`, `file_sha256`, `file_size_bytes`, and `idempotency_key` are present;
- before and after recorder output exists;
- operator timestamps are present;
- file upload/sent-record evidence is present;
- duplicate second file send was not attempted;
- `production_file_send_enabled=false`.
## Current status
No returned R9 package has been reviewed in this repository state.
`isphere_send_file` remains preview/idempotency/audit only:
- `send_file_preview_tool_present=true`
- `send_file_duplicate_detected=true`
- `send_file_conflict_blocked=true`
- `send_file_production_enabled=false`
## Next decision
If a returned package passes `strict_file_gate_pass`, the next implementation round may design a gated file-upload connector path.
If no returned package is available, keep file sending at preview only and continue local-safe rounds.

View File

@@ -0,0 +1,140 @@
# Send Message Connector Selection
Date: 2026-07-10
## Goal
Select the first safe connector for `isphere_send_message`, or block send implementation with an exact evidence request.
This node does not implement message sending, does not click/type in the client, does not send a test message, and does not capture network traffic. It reviews committed repo evidence and the current local implementation only.
## Current prerequisites
Target resolution is now usable for connector work:
- `isphere_search_contacts` has deterministic exact-match-first ranking and case-insensitive de-duplication.
- `isphere_search_groups` has deterministic exact-match-first ranking and case-insensitive de-duplication.
- MCP send contract already defines preview/production modes, `content_sha256`, `idempotency_key`, target metadata, and write errors.
The missing part is the write connector.
## Connector candidates
| Priority | Candidate | Evidence reviewed | Can preview target/content? | Can execute exactly once? | Can confirm success? | Decision |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | Existing bridge/API/local service | Repository search and committed docs/code. | No callable bridge/API/local write service found. | No. | No. | Not selected. |
| 2 | UIA helper action chain | `native/ISphereWinHelper/Program.cs`, `internal/uiaselector/catalog.go`, internal-sandbox runbook. | No validated conversation-open, draft, send-button, or result selectors. | No helper op for click/type/send/invoke. | No post-send confirmation evidence. | Not selected. |
| 3 | Network/protocol connector | Current PacketReader/Smark/SaveToDB evidence and protocol docs. | Read/transport evidence exists, but no authorized sent-message capture or replay/wrapper contract. | No idempotent write operation proven. | No ack/result mapping proven. | Not selected. |
| 4 | Preview-only mock/contract | `docs/mcp-core-tools-contract.md`. | Contract shape exists. | Production send unavailable. | Not applicable. | Not enough for R6 production path. |
## Evidence details
### Existing bridge/API/local service
No committed callable local service or API wrapper was found for sending messages. Current Go MCP code registers read/observation tools only.
### UIA helper action chain
Current helper operations are limited to:
```text
version
self_check
scan_windows
dump_uia
```
Current selector catalog uses `AllowedUseReadOnlyLocate`. It identifies structural areas such as main window, panels, roster, and fuzzy search edit, but it does not validate:
- target search execution;
- conversation open;
- message editor focus;
- draft insertion;
- send button location/action;
- pre-send preview confirmation;
- post-send success/failure confirmation.
The internal-sandbox operator runbook also only authorizes read-only capture actions. It does not provide an approved write-action evidence package.
### Network/protocol connector
Current evidence includes decrypted receive-side/transport logs, but there is no scoped authorized capture of a sent test message and no stable write request/response contract.
Missing protocol evidence:
- endpoint or stanza used for text send;
- auth/session model at a wrapper level without storing secrets;
- target id format for direct and group sends;
- request id/idempotency field or equivalent exactly-once guard;
- success ack/failure state mapping;
- rate limit/retry behavior.
## Decision
No send-message connector is selected for R6.
`isphere_send_message` remains blocked for production implementation.
R6 should not start until one of the evidence packages below is available.
## Exact evidence request
Any one of these can unblock R6:
### Option A: bridge/API/local service evidence
Provide a committed or operator-supplied wrapper spec that proves:
- endpoint/command name;
- accepted target ids for contact and group;
- preview/dry-run behavior;
- production execution call;
- idempotency key handling;
- success/failure response shape;
- sandbox-only verification command.
### Option B: UIA action-chain evidence package
Provide a new internal-sandbox package that explicitly allows and records a test send workflow:
- manually logged-in sandbox account;
- test target only;
- selector evidence for search/open conversation/message editor/send control;
- pre-send preview state;
- one intentional test send;
- post-send confirmation state;
- failure states such as target not found or disabled send button;
- operator note that the send was approved and limited to the test target.
### Option C: network/protocol evidence package
Provide an authorized scoped capture for one test send:
- capture scope and timing;
- host/port/protocol classification;
- redacted request/response shape;
- target id and message id fields, redacted where needed;
- success ack/failure mapping;
- session/auth handling at a high level without secrets.
## Next route
Because R5 did not select a connector:
- R6 production send implementation is blocked.
- R7/R8 send-file work is also blocked behind the send-message connector and upload/file-transfer evidence.
- The project should pause write-side implementation and request one of the evidence packages above.
## R5b update from returned live probe
The later returned live-probe package is documented in `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`.
It changes this R5 conclusion from “waiting for any connector evidence” to “connector evidence exists, but production send remains blocked”:
- B-route runtime discovery now passes for a real x86 `IMPlatformClient.exe` `4.1.2.6842` process.
- The live install contains the expected B-route DLL candidates.
- A-route UIA selectors for search, draft editor, send button, and file-send menu are visible.
- Runtime send entrypoint reachability and success/ack mapping are still unproven.
- The captured chat windows show an offline-send blocker.
The next node is therefore R6a/C31 non-mutating send connector preflight, not R6 production send.

View File

@@ -0,0 +1,33 @@
# C15 Send Message Source Evidence Precheck
Date: 2026-07-10
Loop: Stage C / C15
Target tool: `isphere_send_message`
## Question
Can `isphere_send_message` be implemented now from current committed evidence, without guessing a write connector?
## Evidence reviewed
| Candidate source | Evidence | Result |
| --- | --- | --- |
| Existing bridge/API/local service | Repository search over committed docs/code for `send_message`, bridge/API/local service write paths, and MCP contract references | No committed callable bridge/API/local service for sending was found. Current Go MCP code exposes read tools only. |
| C# WinHelper | `native/ISphereWinHelper/Program.cs` switch supports only `version`, `self_check`, `scan_windows`, and `dump_uia` | No helper op exists for click, type, draft, send, upload, or invoke. |
| UIA selector catalog | `internal/uiaselector/catalog.go` defines selectors with `AllowedUseReadOnlyLocate`; catalog includes main window, left panel, roster, fuzzy search edit | Catalog is read-only locate only and does not validate message input, conversation open, or send button selectors. |
| N12R internal sandbox evidence | `docs/internal-sandbox-live-capture-plan.md` and `docs/internal-sandbox-operator-runbook.md` allow only `version`, `self_check`, `scan_windows`, `dump_uia` and forbid clicking, typing, sending, uploading, and downloading | N12R evidence is structural/read-only. It does not authorize or prove a write path. |
| Business contract | `docs/mcp-core-tools-contract.md` defines `isphere_send_message` with preview/production modes and write errors such as `WRITE_NOT_READY` and `PREVIEW_ONLY` | Contract is ready, but source connector is not validated. |
## Decision
`isphere_send_message` is blocked for implementation. Current evidence supports only contract planning and possibly a future preview-only design, not a production or UIA write connector.
Required unblock evidence:
1. A confirmed existing bridge/API/local service that can send messages, or
2. An internal-sandbox UIA action evidence package that validates conversation resolution, draft entry, send-button location, pre-send confirmation, and result verification, or
3. A validated network/protocol connector with authentication/session handling and audit boundaries.
## Next loop recommendation
Do not implement write-capable behavior in C16. Use C16 for read-path hardening: support directory or multi-log configuration for PacketReader sources so the existing receive/contact/group/file-list tools become more useful with operator-local evidence.

View File

@@ -0,0 +1,151 @@
# Send Sandbox Gate
Date: 2026-07-10
Node: R6c online sandbox-send evidence gate
## Local constraint
The local development environment cannot log in to iSphere / IMPlatformClient because it has no reachable service endpoint. Therefore R6c cannot be completed by a local live send.
Decision for this loop: prepare a returnable online sandbox evidence package instead of attempting any local send.
## Package produced
Script:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-sandbox-gate.ps1
```
Verification:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-sandbox-gate-package.ps1
```
Generated package:
```text
runs/send-sandbox-gate-package/
runs/send-sandbox-gate-package.zip
```
Companion capability-test package:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-send-capability-test.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-capability-test-package.ps1
```
```text
runs/send-capability-test-package/
runs/send-capability-test-package.zip
```
The capability-test package is local/offline safe. It runs `isphere-capability-smoke.exe`, confirms the nine-tool MCP surface, exercises receive/search/file-list against synthetic fixtures, verifies send preview, and confirms `production_send_enabled=false`.
The package embeds the existing read-only live probe recorder under:
```text
recorder/ISphereLiveProbeRecorder.exe
recorder/variants/ISphereLiveProbeRecorder-x86.exe
```
## Package files
| File | Purpose |
| --- | --- |
| `README.txt` | Explains local login limitation, online sandbox requirement, and one-send rule. |
| `OPERATOR-STEPS.md` | Step-by-step operator procedure. |
| `RETURN-CHECKLIST-V2.md` | Strict-v2 return checklist with required manual timestamps and before/after evidence flags. |
| `SANDBOX-SEND-INPUTS.schema.json` | Machine-readable schema for the strict-v2 operator input fields. |
| `SANDBOX-SEND-INPUTS.template.json` | Template for sandbox target, content hash, idempotency key, and observed result. |
| `EXPECTED-RETURN-FILES.txt` | Return package acceptance checklist. |
| `RUN-RECORDING-SUITE.bat` | Top-level guided before/manual-send/after/return-zip flow. |
| `01-run-before-recorder.bat` | Numbered wrapper for before recording. |
| `02-run-after-recorder.bat` | Numbered wrapper for after recording. |
| `03-make-return-zip.bat` | Numbered wrapper for return zip creation. |
| `RECORDING-PACKAGE-MANIFEST.json` | Machine-readable package manifest and one-send markers. |
| `run-before-recorder.bat` | Runs read-only recorder before the manual sandbox send. |
| `run-after-recorder.bat` | Runs read-only recorder after the manual sandbox send. |
| `compute-content-hash.ps1` | Computes SHA256 for the exact test message body. |
| `CREATE-RETURN-ZIP.ps1` | Strict-v2 return zip creator; refuses missing after-recorder output or required v2 fields. |
| `make-return-zip.ps1` | Compatibility wrapper that calls `CREATE-RETURN-ZIP.ps1`. |
## Operator rules
The package encodes these explicit markers:
```text
LOCAL_LOGIN_UNAVAILABLE
ONLINE_SANDBOX_REQUIRED
MANUAL_ONE_SEND_ONLY
DO_NOT_SEND_SECOND_TIME
content_sha256
idempotency_key
success_ack_or_sent_record
operator_started_at_local
operator_clicked_send_at_local
operator_observed_success_at_local
before_recorder_present
after_recorder_present
success_ack_or_sent_record_present
duplicate_second_send_attempted
```
Required online procedure:
1. Log in normally on an online/internal sandbox machine.
2. Fill `SANDBOX-SEND-INPUTS.json` from the template.
3. Run `RUN-RECORDING-SUITE.bat` for the guided all-in-one flow, or run `01-run-before-recorder.bat`.
4. Manually send exactly one message to the declared sandbox target.
5. Record `success_ack_or_sent_record`.
6. Do not send a second copy.
7. Continue the guided flow, or run `02-run-after-recorder.bat`.
8. Set the strict-v2 booleans in `SANDBOX-SEND-INPUTS.json`: `before_recorder_present=true`, `after_recorder_present=true`, `success_ack_or_sent_record_present=true`, and `duplicate_second_send_attempted=false`.
9. Continue the guided flow, or run `03-make-return-zip.bat`.
10. Return the generated zip.
## Safety boundary
The package does not:
- send messages automatically;
- upload files;
- click or type;
- inject or hook;
- capture network traffic;
- enable production send.
The only send action is the operator's one manual sandbox send inside the official client.
## Acceptance gate
R6c can pass only after a returned package proves all of the following:
- explicit sandbox target;
- exactly one content hash;
- exactly one idempotency key;
- observed success/ack or local sent-record evidence;
- confirmation that no second send was made;
- before/after read-only recorder outputs are present.
- strict-v2 fields are complete: `operator_started_at_local`, `operator_clicked_send_at_local`, `operator_observed_success_at_local`, `before_recorder_present`, `after_recorder_present`, `success_ack_or_sent_record_present`, and `duplicate_second_send_attempted=false`.
## Current decision
R6c local-preparation slice is complete, including the consolidated recording suite and the separate offline capability-test package. The full online evidence gate is not passed yet.
Production `isphere_send_message` remains blocked.
Next node: **R6d returned sandbox evidence intake** after the user returns the generated package.
## R6j strict-v2 package hardening
R6j upgrades the recording package so the previously missing R6d evidence cannot be omitted silently:
- `scripts\verify-send-sandbox-gate-package.ps1` now requires `RETURN-CHECKLIST-V2.md`, `SANDBOX-SEND-INPUTS.schema.json`, `CREATE-RETURN-ZIP.ps1`, and all strict-v2 field names.
- `CREATE-RETURN-ZIP.ps1` refuses to create the return zip unless before/after recorder directories contain files and strict-v2 fields are present.
- `scripts\validate-returned-send-sandbox-package.ps1` accepts `-ZipPath` and `-StrictV2`; strict mode exits non-zero when the returned package does not prove the full gate.
- The older partial package `C:\Users\zhaoy\Downloads\1631.zip` remains `partial_use_only` and `strict_v2_pass=false`.
Current decision: production `isphere_send_message` remains blocked. The next returned package must be generated from the R6j strict-v2 package to pass the full gate.

View File

@@ -0,0 +1,360 @@
# Send Connector B-First Plan
Date: 2026-07-10
Node: C29 / write-side connector route correction
Decision owner: user/business manager
## Decision
The selected send route is now:
1. **Primary route B: in-process / sidecar connector** that reuses the already logged-in `IMPlatformClient.exe` runtime and its existing message/file send classes.
2. **Fallback route A: constrained UI/RPA wrapper** only if B cannot attach, load, or invoke safely.
3. **Network replay/protocol reimplementation remains deferred** because it would require independently reproducing login/session/ack behavior.
This document supersedes the previous C15/R5 conclusion for future send work. The old conclusion was correct for the evidence available at that time: no write connector had been selected. This node records the newly approved route and the evidence that makes B worth trying first.
## Business goal
Expose controlled MCP actions for digital employees:
- `isphere_send_message`
- `isphere_send_file`
The implementation must preserve these business controls:
- target must be resolved from existing contact/group search results;
- text/file content must be previewed before execution;
- execution must use an `idempotency_key` and content hash;
- every outbound attempt must produce an audit record;
- early versions should support sandbox/test-target verification before normal production use.
## Static evidence summary
Offline package evidence under:
```text
runs/offline-evidence-intake/zyl-qqfile-20260709/
```
Relevant extracted binaries:
```text
zyl/Impp/IMPlatformClient.exe
zyl/Impp/smack.dll
zyl/Impp/IMPP.Interface.dll
zyl/Impp/IMPP.ServiceBase.dll
zyl/Impp/IMPP.Service.dll
zyl/Impp/TcpFileTransfer.dll
zyl/Impp/HYHC.IMPP.DAL.dll
```
Relevant IL snapshots:
```text
runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/IMPlatformClient.exe.il
runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/smack.dll.il
runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/IMPP.Interface.dll.il
runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/IMPP.Service.dll.il
runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/IMPP.ServiceBase.dll.il
```
## Text-send call chain
### Preferred sidecar candidate
`IMPlatformClient.exe` contains a high-level helper:
```text
IMPP.Client.Core.Plugins.Manager.AppContextManager.SendTxtMessageByJid(jid, chatType, content)
```
Observed behavior from IL:
- for direct/P2P chat, it gets a `P2PChatManager` from `IMPPManager.Instance`;
- it resolves a `Chat` by JID;
- it finds or opens a chat form;
- it calls `frmP2PChat.SendTxtMessage(content)`;
- for temporary/reserved group chat, it calls the corresponding room form send method.
This is the best B-route first target because it reuses the logged-in client's own state and UI/business rules.
### Central message construction candidate
`IMPlatformClient.exe` also contains:
```text
IMPP.Client.Core.MessageEngine.MessageScheduling.SendChatMessage(chat, msgText, toJid, messageType, isSaveMessage, msgGuid, IsReceipt)
```
Observed behavior from IL:
- reads local font/style settings;
- builds message body via `UtilsText.GetMessageStyleBodyText`;
- decides `chat` vs `groupchat`;
- creates `smack.packet.XmppMessage`;
- sets body, subject, offline flag, receipt flag, time, and message type;
- calls `chat.SendMessage(xmppMessage)`;
- optionally persists/parses message through `MessageManager.MessageParsing`.
This is the cleanest lower-level B candidate if the sidecar can obtain a valid `Chat` object from the running client context.
### Protocol bottom
`smack.dll` provides the real wire-send chain:
```text
com.vision.smack.Chat.SendMessage(XmppMessage)
-> com.vision.smack.XMPPConnection.send(string)
-> Sharp.Xmpp.Core.XmppCore.Send(string)
-> stream.Write(...)
```
This proves that the send capability exists in the installed client binaries. It also shows why an external reimplementation is less attractive: it would need the same active connection, stanza format, message id, receipt, and session state.
## Important trap: plugin interface text send is not enough
`IMPP.Interface.dll` declares:
```text
IAppContextManager.SendP2PMessage(string pJid, string pMessage)
IAppContextManager.SendP2PFiles(UserInfo[] pUserInfo, string[] pFiles)
IAppContextManager.SendChatRoomMessage(string pRoomJid, string pMessage, string pluginID)
IAppContextManager.SendPluginMessage(string pluginID, int command, string para)
```
But the implementation of:
```text
IMPP.Client.Core.Plugins.Manager.AppContextManager.SendP2PMessage(...)
```
records the plugin call and then throws `NotImplementedException`.
Therefore the B route must **not** choose `SendP2PMessage` as the text-send entry. The sidecar should prefer `SendTxtMessageByJid` or, if necessary, `MessageScheduling.SendChatMessage` with an existing `Chat` object.
## File-send call chain
File sending appears split into upload plus file-message notification.
### Upload engine
`IMPP.ServiceBase.dll` defines `IFileTransfer`.
`IMPP.Service.dll` implements:
```text
IMPP.Service.BLL.FileTransfer.FileUpload(...)
IMPP.Service.BLL.FileTransfer.AsynFileUpload(...)
IMPP.Service.BLL.FileTransfer.FileDownload(...)
IMPP.Service.BLL.FileTransfer.SyncFileTransfer(...)
```
### Offline file send
`IMPlatformClient.exe` contains:
```text
IMPP.Client.OffLineFileSend.sendFile()
IMPP.Client.OffLineFileSend.trans_UploadCompleted(fileId)
```
Observed behavior:
- builds a generated file GUID;
- builds remote name as `filename____guid.ext`;
- uses `Config.ServerInfo.HTTPFileServer` and `FileTransferManager.GetDomain`;
- creates `FileUploadPara`;
- calls `IMPPManager.Instance.Service.FileTransfer.FileUpload(uploadPara)`;
- after upload completion, creates an `OFFLINE_FILE_TRANSFER;...` message body;
- calls `Chat.sendFileMessage(...)`;
- sets packet id to `fileId` and records metadata.
### Native TCP file transfer
`TcpFileTransfer.dll` is native and exposes strings/exports indicating:
```text
Send
SendFile
GetTcpFileMsgGuid
_StartWaitForSendFileThread@8
_StartRecvFileThread@24
```
This is a candidate for online/TCP file transfer, but the first B route should use the managed offline-file path because it is closer to the normal client business flow and already integrates upload metadata with XMPP file messages.
## Proposed sidecar architecture
```text
Go MCP Server
-> controlled action contract and audit
-> C# WinHelper / Sidecar command
-> B-route sidecar adapter
-> running IMPlatformClient context
-> existing AppContextManager / MessageScheduling / OffLineFileSend / smack.dll
```
The sidecar should be a narrow adapter, not a general-purpose remote code executor.
### Candidate sidecar operations
```text
version
self_check
probe_client_runtime
probe_send_entrypoints
resolve_runtime_jid
preview_send_message
send_message_after_approval
preview_send_file
send_file_after_approval
read_send_audit
```
The first implementation slice should stop before real production send:
```text
version
self_check
probe_client_runtime
probe_send_entrypoints
```
Then run a live sandbox observation before enabling `send_message_after_approval`.
## B-route validation gates
### Gate B1: runtime discovery
Pass condition:
- find a running `IMPlatformClient.exe` process;
- record PID, bitness, install path, module list summary;
- confirm the discovered path matches the extracted evidence family;
- confirm whether the sidecar must be x86 or x64.
Stop condition:
- no logged-in process;
- process bitness incompatible with available sidecar build;
- module names differ too much from the extracted package.
### Gate B2: entrypoint availability
Pass condition:
- confirm `IMPlatformClient.exe` exposes `AppContextManager` and `MessageScheduling` in the runtime module set;
- confirm `smack.dll` is loaded or loadable from the same install directory;
- confirm `IMPP.Interface.dll` and `IMPP.Service*.dll` are present.
Stop condition:
- app is not .NET Framework / cannot inspect CLR runtime safely;
- required assemblies are missing;
- only `SendP2PMessage` is available but `SendTxtMessageByJid`/`MessageScheduling` cannot be reached.
### Gate B3: non-mutating dynamic observation
Pass condition:
- during one operator-approved manual test send, observe the expected chain:
- `SendTxtMessageByJid` or UI send method;
- `MessageScheduling.SendChatMessage`;
- `Chat.SendMessage`;
- `XMPPConnection.send`;
- capture only redacted method names, argument shape, message id/hash, and success/ack indicator.
Stop condition:
- method chain differs from static evidence;
- no success/ack indicator can be mapped;
- message id/idempotency cannot be correlated.
### Gate B4: sidecar dry run
Pass condition:
- sidecar can resolve the target JID/chat type;
- sidecar can build a preview object without sending;
- preview contains target, content hash, connector, and proposed entrypoint;
- audit event is appended.
Stop condition:
- no deterministic target resolution;
- preview cannot distinguish direct vs group chat;
- no auditable dry-run object.
### Gate B5: one sandbox send
Pass condition:
- one explicit test target only;
- one idempotency key only;
- one message content hash only;
- success/ack or local sent-record evidence captured;
- duplicate retry with same idempotency key is rejected locally.
Stop condition:
- duplicate send risk;
- wrong target risk;
- no post-send verification.
## A-route fallback
A remains the backup route if B cannot be made reliable.
A should not be arbitrary desktop control. It should expose only:
```text
search_contact(query)
open_conversation(target)
write_draft(text)
verify_draft(target, text_hash)
send_after_approval(approval_id)
upload_file(path)
verify_send_result()
```
A-route fallback is activated when any of these happen:
- B cannot attach/probe safely;
- required runtime classes are unavailable in the logged-in process;
- B can observe but cannot invoke without unstable side effects;
- B cannot provide idempotency/audit guarantees;
- sidecar bitness/runtime mismatch is too costly to resolve in the current cycle.
## MCP contract impact
The MCP business tools should not expose B/A internals directly. They should keep the stable user-facing shape:
```text
isphere_send_message(target_type, target_id, content_text, mode, idempotency_key, content_sha256)
isphere_send_file(target_type, target_id, file_path, mode, idempotency_key, file_sha256)
```
Connector metadata should show the route:
```text
connector = "implatform-sidecar" | "uia-rpa"
connector_stage = "preview" | "sandbox_send" | "production"
```
## Next implementation loop
R6c local preparation is now complete. Because the local development machine cannot log in, the online sandbox-send gate was converted into a returnable evidence package rather than a local live-send attempt.
Next node should be **R6d returned sandbox evidence intake** after the package is run in an online/internal sandbox and returned:
1. Check completed `SANDBOX-SEND-INPUTS.json`.
2. Check before/after read-only recorder outputs.
3. Confirm one explicit sandbox target, one content hash, and one idempotency key.
4. Confirm `success_ack_or_sent_record`.
5. Confirm no second send was made.
6. Do not enable production `send_message_after_approval` until returned evidence passes.
## Current status
B is selected as the primary send/file-send route. Runtime discovery, entrypoint metadata preflight, preview/dry-run audit, and the online sandbox evidence package are in place. A remains the fallback and now has concrete UIA selector/offline-blocker classifier support. Production send remains blocked until the returned sandbox package proves success/ack behavior.

View File

@@ -0,0 +1,124 @@
# A-route open_conversation 窗口验证说明
日期2026-07-11
分支:`codex/a-route-rpa-send`
> 2026-07-12 校准:本文记录的是 synthetic 离线探针阶段和当时的真实窗体构造阻塞。后续已经通过
> `scripts\open-offline-real-frmP2PChat.ps1` 在未登录/离线环境打开真实 `frmP2PChat`。
> 当前以 `docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md` 为准。
## 本轮目标
用户明确当前先不要求真实发送消息,只要求“打开那个会话窗口”,用来验证数字员工是否能:
1. 识别会话窗口。
2. 定位发送编辑框。
3. 定位发送按钮/文件按钮。
4. 执行一次可审计的点击。
本地环境没有 iSphere 服务端,无法登录,所以真实会话窗口不能通过正常业务路径打开。
## 已实现:离线会话窗口探针
新增脚本:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-chat-window-probe.ps1 -ProbeClick
```
它会打开一个本地 WinForms 会话窗口探针,不连接服务端,不发送真实消息。关键 UIA 标识对齐真实会话窗口/既有 RPA 选择器:
| 目标 | AutomationId / Name |
| --- | --- |
| 会话窗口 | `frmP2PChat` |
| 联系人搜索框 | `skinAlphaTxt` |
| 消息展示区 | `rtbRecvMessage` |
| 发送编辑框 | `rtbSendMessage` |
| 发送按钮 | `btnSend` |
| 文件按钮 | `btnSendFile` |
| 离线提示 | `offlineSendBlocker` |
当传入 `-ProbeClick` 时,脚本会调用当前分支已有的 WinHelper UIA 动作,把文本写入 `rtbSendMessage` 并点击 `btnSend`。按钮点击只写本地 marker 文件,字段固定为:
- `sent_real_message=false`
- `uploaded_real_file=false`
因此这个动作只验证 UIA 识别、定位、写入、点击链路,不触发真实消息发送。
## 自动化测试
新增测试:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\test-open-offline-chat-window-probe.ps1
```
测试断言:
- 根窗口 `root_automation_id=frmP2PChat`
- 找到 `rtbSendMessage`
- 找到 `btnSend`
- 找到 `btnSendFile`
- `ProbeClick` 后写入 marker
- marker 文本与 UIA 写入文本一致
- `sent_real_message=false`
- `uploaded_real_file=false`
## 真实 `frmP2PChat` 破解进度
已继续尝试直接构造真实类型:
```text
IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat
```
构造函数签名:
```text
frmP2PChat(com.vision.smack.Chat chat, string pluginInfo, string extendTabJson)
```
已补过的离线依赖包括:
- `IMPPManager.Instance.MessageCenter`
- `IMPPManager.Instance.LogonUser`
- `IMPPManager.Instance.UserInfo`
- `SmarkManager.Connection`
- `SmarkManager.RosterManager`
- `SmarkManager.PresenceManager`
- `SmarkManager.P2PChatManager`
- `ConfigSystemManager.config`
- `P2PChat.OtherJid`
本节以下内容是当时的中间判断。后续 Task 7 已经补齐更多离线依赖并成功打开真实窗体因此这里不再作为当前阻塞。它仍保留为风险索引构造函数内部确实把窗体初始化、登录态、连接态、名册状态、消息中心事件、配置状态混在一起。IL 证据显示构造函数中直接访问:
- `IMPPManager.Instance.Connection.add_OnConnectError`
- `IMPPManager.Instance.Connection.add_OnReConnectOk`
- `P2PChat.OtherJid.getBareJid`
- `IMPPManager.Instance.UserInfo.get_Jid`
- `UCChatSendMessageBox.GetChatRichTextBox`
- `UCChatSendMessageBox.GetReceiptCheckBox`
- `IMPPManager.Instance.GetMessageCenter().add_OnTcpMessageArrived`
- `IMPPManager.Instance.RosterManager.UpdateStrangerStatus`
- `Config.BaseConfig.VisualPhoneEnable`
后续解决方式不是走正常登录链路,而是建立更完整的离线依赖 harness并在构造完成后补齐关键用户/JID/配置缓存。当前结论已变更为:真实 `frmP2PChat` 可以离线打开,但真实发送仍会被客户端离线状态阻断。
## 当前业务结论
从“验证 RPA 能否识别、定位、点击会话窗口”的业务目标看,本轮已经可验证:
- 会话窗口可打开。
- 核心 AutomationId 可识别。
- 文本可写入。
- 按钮可点击。
- 点击结果可留本地审计 marker。
- 全程不发真实消息、不上传真实文件。
从“真实 iSphere 会话窗口”的目标看,后续已完成离线打开真实 `frmP2PChat`。从“真实业务发送”的目标看,仍需要在线登录环境。
## 下一步建议
1. synthetic 探针只作为回归兜底;真实窗口验证优先使用 `open-offline-real-frmP2PChat.ps1`
2. 把真实离线 `frmP2PChat` HWND 接入 `isphere_send_message``uia_rpa` connector做可重复 UI 写入/点击 smoke。
3. 在线环境拿到真实登录后的 `frmP2PChat` HWND 后,用同一套 WinHelper 选择器验证业务发送。

View File

@@ -0,0 +1,177 @@
# A-route 离线打开真实 frmP2PChat 会话窗口
日期2026-07-11
分支:`codex/a-route-open-real-chat-window`
目标:不登录、不连服务端,在本机离线样本中打开真实 `IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat` 窗口,供后续 RPA 对真实窗口做 UIA 控制。
## 结论
已经在未登录/离线环境下打开真实 `frmP2PChat`
成功命令:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps -ShowWindow -HoldSeconds 1
```
成功产物:
- 结果 JSON`E:\coding\codex\isphere-ai-bridge\runs\offline-chat-window\real-frmP2PChat-result-20260711-205312.json`
- HWND`0xC0CB4`
- 截图:`E:\coding\codex\isphere-ai-bridge\runs\offline-chat-window\real-frmP2PChat-20260711-205312.png`
- UIA dump`E:\coding\codex\isphere-ai-bridge\runs\offline-chat-window\uia-dump-real-frmP2PChat-20260711-205312.json`
- UIA root
- `automation_id = frmP2PChat`
- `control_type = Window`
- `framework_id = WinForm`
- `visible = true`
UIA dump 已确认可见关键控件:
- `rtbRecvMessage`
- `rtbSendMessage`
- `btnSend`
窗口内显示“您已处于离线状态,无法发送消息,请上线后再次尝试!”。这是预期状态;本轮目标不是发送消息,也不是上传文件,只是打开真实会话窗口。
## 新增脚本
### 1. 真实窗口打开脚本
`scripts/open-offline-real-frmP2PChat.ps1`
关键能力:
- 自动切到 32-bit PowerShell避免 x86 客户端程序集 `BadImageFormatException`
- 只读加载真实客户端目录:
`runs/offline-real-client-window/full/zyl/Impp`
- 使用 `AssemblyResolve` 从真实客户端目录解析依赖。
- 构造真实类型:
`IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat`
- 支持不 Show 的构造验证,也支持 `-ShowWindow` 真实显示、截图和 UIA dump。
- 捕获 WinForms `ThreadException`,输出 IL offset、堆栈和 JSON 证据,避免 .NET 异常弹窗卡死。
### 2. 构造函数 IL / NullRisk 提取脚本
`scripts/extract-frmP2PChat-il-risk.ps1`
输出:
- 完整构造函数 IL
`runs/offline-chat-window/frmP2PChat-ctor.il.txt`
- `callvirt` / `ldfld` NullRisk CSV
`runs/offline-chat-window/frmP2PChat-ctor-null-risk.csv`
- 风险摘要:
`runs/offline-chat-window/frmP2PChat-ctor-null-risk.md`
提取结果:
- 构造函数源 IL 行:`406271-406860`
- 构造函数行数:`590`
- `callvirt` / `ldfld` 风险项:`150`
## 关键 IL 证据
构造函数签名:
```text
frmP2PChat(com.vision.smack.Chat chat, string pluginInfo, string extendTabJson)
```
高信号 NullRisk
| IL | 证据 | 需要补的运行态 |
|---|---|---|
| `IL_00f6` | `XMPPConnection::add_OnConnectError` | `IMPPManager.Instance.Connection` / backing field |
| `IL_0111` | `XMPPConnection::add_OnReConnectOk` | 同上 |
| `IL_013b` / `IL_0140` | `P2PChat::get_OtherJid()` / `Jid::getBareJid()` | `P2PChat.OtherJid` |
| `IL_0156` | `IMPPManager::get_UserInfo()` | `IMPPManager._userInfo` |
| `IL_0575` | `MessageCenter::add_OnTcpMessageArrived` | `IMPPManager.MessageCenter` |
| `IL_0614` | `RosterManager::UpdateStrangerStatus` | `IMPPManager.RosterManager` |
| `IL_0631` | `BaseConfig::VisualPhoneEnable()` | `Config.BaseConfig` |
Show 阶段新增证据:
| IL | 证据 | 处理 |
|---|---|---|
| `frmP2PChat_Load IL_004d/0058` | `Config::get_BaseConfig()``BaseConfig::RemoteControlVisible()` | 补 `Config.<BaseConfig>k__BackingField` |
| `frmChatBase_Activated IL_0000/0005` | `IMPPManager.get_Instance().get_frmMain().BringModalFormToFront()` | 补 `IMPPManager.<frmMain>k__BackingField` |
| `frmP2PChat_Load IL_137f` | `UserInfoManager.getUserInfo(otherBareJid,true)` 后使用 `UserInfo::get_CompanyID()` | 构造完成后预热 `UserInfoCache` |
注意:`UserInfoCache` 不能在 `frmP2PChat` 构造前预热。过早预热会让构造函数进入 `UCUserInformation.LoadUserInfo(UserInfo)`,在 `UCUserInformation::LoadUserInfo IL_0085` 触发新的 NRE。当前脚本是在真实窗体构造完成后、`Show()` 前预热缓存。
## 最小 fake runtime 清单
当前成功路径最小补齐如下:
1. `IMPPManager.Instance`
- `<MessageCenter>k__BackingField`
- `<frmMain>k__BackingField`
- `LogonUser`
- `_userInfo`
- `_rosterUserInfoList`
2. `SmarkManager`
- `<SmarkManagerInstance>k__BackingField`
3. `XMPPConnection`
- `Jid`
- `UserName`
- `LoginState`
- `CompanyID`
- `Resource`
- `_packetListeners`
- `_packetCollectors`
4. `IMPPManager` inherited manager fields/properties
- `Connection` and `<Connection>k__BackingField`
- `IsConnected` and `<IsConnected>k__BackingField`
- `PresenceManager` and backing field
- `P2PChatManager` and backing field
- `RosterManager` and backing field
5. Config
- `ConfigSystemManager.config`
- `Config.<BaseConfig>k__BackingField`
- `Config.<UserInfo>k__BackingField`
- `Config.<LoginInfo>k__BackingField`
6. `UserInfoCache`
-`frmP2PChat` 构造后、`Show()` 前加入自己和对端的 JID/ID 映射。
7. Chat 对象
- 真实 `com.vision.smack.P2PChat`
- `OtherJid = codex-probe@offline.local`
## 验证命令
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-extract-frmP2PChat-il-risk.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\extract-frmP2PChat-il-risk.ps1 -OutDir runs/offline-chat-window
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-open-offline-real-frmP2PChat-contract.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\open-offline-real-frmP2PChat.ps1 -RuntimeMode Deps -ShowWindow -HoldSeconds 1
```
## 当前边界
- 已成功打开真实 `frmP2PChat`
- UIA 可 dump 到真实 `frmP2PChat``rtbRecvMessage``rtbSendMessage``btnSend`
- 离线环境下窗口会提示无法发送消息,这是客户端真实离线状态表现,不影响后续 RPA 窗口定位。
- 本轮没有处理真实发送、真实上传、真实登录态。
- `btnSendFile` 在这次真实离线 UIA dump 中没有以 `btnSendFile` automation id 暴露;后续如果 RPA 需要上传文件,需要另开节点定位真实工具栏按钮或菜单路径。
## 2026-07-12 主分支状态校准
该能力已经随 A-route 分支合并到 `main`。业务含义如下:
- 已完成:真实离线 `frmP2PChat` 打开、UIA 识别、发送框定位、发送按钮定位。
- 已演示:在真实离线窗口内写入文本并点击 `btnSend`,可用于验证数字员工的窗口识别、定位和点击链路。
- 未完成:在线登录态下的真实发送、服务端 ack、接收方送达、发送记录匹配。
- 未完成:真实文件上传/发送。当前真实 UIA dump 没有稳定暴露 `btnSendFile`,文件发送要单独做工具栏/菜单定位。
因此,文档里出现的 helper `sent_message=true` 只能解释为“UI 发送动作已触发”,不能解释为“业务消息已送达”。
## 下一轮建议
1. 把 A-route RPA 的窗口定位目标从 synthetic probe 切到真实 `frmP2PChat`
- root `automation_id=frmP2PChat`
- send box `automation_id=rtbSendMessage`
- send button `automation_id=btnSend`
2. 把真实窗口 HWND 接入 `isphere_send_message``uia_rpa` connector形成可重复的本地 UI 动作 smoke。
3. 在线登录环境中补一次业务发送验证发送前后记录、content hash、ack/发送记录、idempotency 不重复发送。
4. 如果后续需要文件按钮,再专门分析真实工具栏图标按钮、菜单项和 `ToolStrip` 命中路径。

View File

@@ -0,0 +1,52 @@
# A-route UIA Send Implementation Note
Date: 2026-07-11
Branch: implemented on `codex/a-route-rpa-send`, merged to `main`
## Decision
The A-route fallback is now function-first. It does not introduce an approval ID gate inside the MCP tool or connector. Digital employee policy can decide when to call `execution_mode="production"`; the connector focuses on performing the UI action when explicitly configured.
## Implemented local-safe proof
- `SendMessageConnectorRequest` now carries `ContentText` to the connector only.
- `internal/tools/send_message_uia_adapter.go` maps production send requests to helper op `uia_send_message`.
- `native/ISphereWinHelper/UiaSendAction.cs` sets the send editor text and invokes the send button by UI Automation / Win32 fallback.
- `scripts/verify-win-helper.ps1` uses a synthetic WinForms window to prove write + button invoke without requiring iSphere login.
## Runtime configuration
```powershell
$env:ISPHERE_SEND_CONNECTOR_MODE = "uia_rpa"
$env:ISPHERE_SEND_UIA_HWND = "0x001A0B2C"
$env:ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID = "rtbSendMessage"
$env:ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID = "btnSend"
```
Then call `isphere_send_message` with `execution_mode="production"`.
## Status correction after real-window work
`uia_send_message` is a UI action connector. Its helper result fields such as
`typed_text=true`, `clicked_ui=true`, and `sent_message=true` mean the editor was
written and the send button was invoked/clicked. They do not mean the server
accepted the message, the receiver got it, or a sent record was written.
After this note was first written, the project also opened a real offline
`frmP2PChat` window locally and confirmed:
- root: `frmP2PChat`
- editor: `rtbSendMessage`
- send button: `btnSend`
- receive area: `rtbRecvMessage`
The real offline window still shows the client-side offline-send blocker, so it
is useful for UI positioning and click verification, not for business delivery
verification.
## Remaining real-environment work
The local environment still cannot log in to iSphere. The code path is
implemented and locally proven against synthetic UIA plus the real offline chat
window, but real iSphere success still needs a logged-in window handle and one
online run with success/ack or sent-record evidence.

View File

@@ -0,0 +1,175 @@
# A-route RPA 用户无感运行说明
日期2026-07-11
## 目标
A-route 当前看重窗口和 UIA 控件,而不是背后的网络逻辑。这里的“用户无感”指:
1. 不遮挡当前用户桌面。
2. 不抢焦点。
3. 不接管鼠标键盘。
4. 数字员工仍然能通过 HWND/UIA 找到目标窗口和控件。
5. 所有动作可记录、可复现、可回收。
## 当前已实现
脚本:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent
```
效果:
- 从完整离线客户端目录启动 `IMPlatformClient.exe`
- 使用 `-WindowStyle Minimized` 降低启动阶段闪窗概率。
- 找到真实 `IMPlatformClient` 窗口后移动到屏幕外:
- 主窗:`x=-32000, y=-32000`
- 提示窗:同样移到屏幕外
- 设置 `SWP_NOACTIVATE`,不调用前台激活。
- 不生成屏幕截图,避免因为窗口在屏幕外得到无意义图片。
- 继续输出 UIA dump供 RPA 选择器验证。
## 抗干扰能力
新增守护模式:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -WindowMode Visible -WatchSeconds 30 -PollIntervalMs 500
```
也可和无感模式一起使用:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent -WatchSeconds 30 -PollIntervalMs 500
```
当前守护逻辑:
- 用户手动移动主窗口:检测到位置/大小偏离后自动移回目标位置。
- 用户手动移动提示窗:检测到位置/大小偏离后自动移回目标位置。
- 用户关闭主窗口/杀掉客户端进程:检测到主窗口消失后自动重新启动离线客户端。
- 每次守护输出:
- `watch_iteration_count`
- `repair_count`
- `relaunch_count`
- `recovery_actions`
本轮实际验证:
1. 模拟移动窗口到 `760,260,310x610`
- 守护输出 `repair_count=1`
- `recovery_actions=["repair_window_placement"]`
- 最终窗口恢复到 `120,120,270x570`
2. 模拟关闭客户端进程:
- 被关闭 PID`11592`
- 守护输出 `relaunch_count=1`
- 新启动 PID`10504`
- `recovery_actions=["relaunch_missing_main_window","repair_after_relaunch"]`
- 最终 UIA 根控件仍为 `frmLogin`
抗干扰强度结论:
- 对“用户移动窗口”:强。
- 对“用户关闭窗口/进程”:中强,可自动重启。
- 对“用户在关键发送瞬间抢焦点/操作同一窗口”:当前还不是强,需要动作级锁定和发送前后校验。
- 对“应用崩溃/服务端不可用”:只能自动拉起窗口;业务登录/聊天能力仍受真实服务端影响。
本轮验证结果:
- 新启动进程:`IMPlatformClient.exe`
- PID`20060`
- 主窗口:`0x340330`
- UIA 根控件:
- `automation_id=frmLogin`
- `framework_id=WinForm`
- `class_name=WindowsForms10.Window.8.app.0.d3a00f_r7_ad1`
- UIA dump 仍可读,即使窗口处于屏幕外:
- `is_offscreen=true`
- 子控件 `skinState``skinLoadPanel` 可见于控件树
## 推荐无感等级
### L1当前用户会话内屏幕外运行
适合当前开发机验证:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -UserSilent
```
优点:
- 实现最快。
- 不影响当前屏幕。
- UIA 读控件可继续工作。
限制:
- 如果某些发送动作必须依赖真实鼠标点击或 OCR 截图,屏幕外模式不适合。
- 发送路径应优先使用 `ValuePattern``InvokePattern``WM_SETTEXT``BM_CLICK` 等 HWND/UIA/Win32 方式。
### L2当前用户会话内可见但不激活
适合排查窗口状态:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-real-client-window.ps1 -WindowMode Visible -NoActivate
```
优点:
- 人能看到窗口。
- 尽量不抢焦点。
限制:
- 仍可能遮挡用户桌面。
- 只适合调试,不适合长期运行。
### L3独立 Windows 用户会话运行
这是生产上更稳的“真正无感”方案:
- 给数字员工单独建一个 Windows 用户。
- 在该用户会话里登录 iSphere。
- RPA/WinHelper/MCP 都运行在同一个独立会话。
- 业务用户使用自己的桌面,看不到数字员工窗口。
优点:
- 不影响业务用户桌面。
- 不抢业务用户焦点。
- 可以保留真实可见窗口,兼容 OCR/坐标兜底。
限制:
- 需要部署层面的账户和会话管理。
- WinHelper 必须和目标窗口在同一交互式桌面会话里运行。
### L4不用 RPA走 B-route/API/sidecar
这是最终最无感的方向:
- 没窗口。
- 不依赖桌面。
- 不受焦点、分辨率、UI 改版影响。
但当前分支是 A-route RPA所以本轮先把 L1/L2 做出来。
## 结论
当前已经具备 L1 无感基础能力:
- 真实客户端可打开。
- 可屏幕外运行。
- 不需要前台焦点。
- UIA 控件树仍可读取。
下一轮应把发送/搜索动作约束在非焦点方式上:
1. 优先 UIA `ValuePattern`/`InvokePattern`
2. 其次 HWND 消息 `WM_SETTEXT`/`BM_CLICK`
3. 最后才考虑坐标/OCR坐标/OCR 只能放到独立 Windows 用户会话里做,不能放当前用户桌面长期运行。

View File

@@ -0,0 +1,309 @@
# B-Route Bridge/IPC/Plugin Static Map
Date: 2026-07-12
## Scope
This report uses offline IL, binary string metadata, reflection metadata for `HttpServerLib.dll`, and returned live-probe JSON. It does not log in, open sockets, attach hooks, inject into a process, replay traffic, or mutate client binaries.
Machine-readable evidence: `runs/offline-broute-reverse/bridge-map.json`
Reflection metadata: `runs/offline-broute-reverse/httpserverlib-reflection.json`
## Recommendation
| Question | Answer |
| --- | --- |
| Should B-route pursue existing IPC/plugin before an in-process adapter? | **No** |
| Offline decision | `in_process_adapter_research` |
| External sidecar boundary | `external_sidecar_preview_only_until_bridge_exists` |
| Reason | Offline static evidence found no existing local IPC/plugin route that exposes send-message or send-file. HttpServerLib and plugin surfaces either require logged-in runtime state or are UI/framework-only; the named-pipe stub is empty. |
## Sources
### Text/IL sources
| Source | Role | Exists | Lines | Size bytes |
| --- | --- | --- | ---: | ---: |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il` | main client IL; login, plugin manager, local HTTP starter, chat/runtime | yes | 1360035 | 74148460 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Common.dll.il` | common helpers; WinCommand, ProcessComm, DTOs | yes | 118343 | 5703293 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ISphere.exe.il` | ISphere companion app IL; CEF and command helpers | yes | 81273 | 5679074 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.UI.dll.il` | UI/browser support IL | yes | 242314 | 13545362 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il` | file service implementation IL | yes | 3351 | 207786 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il` | XMPP/socks5/network IL | yes | 87425 | 5019807 |
### Binary/string sources
| Source | Role | Exists | Strings | Size bytes |
| --- | --- | --- | ---: | ---: |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll` | local HTTP server framework binary | yes | 555 | 26624 |
| `runs\offline-real-client-window\full\zyl\Impp\IMPlatformClient.Web.exe` | CEF/web helper process binary | yes | 185 | 9216 |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll` | native network abstraction | yes | 563 | 87552 |
| `runs\offline-real-client-window\full\zyl\Impp\IOClientNetwork.dll` | native network implementation | yes | 870 | 145920 |
| `runs\offline-real-client-window\full\zyl\Impp\IMPlatformClient.exe` | main client binary | yes | 76877 | 8366592 |
## Candidate classification
| Candidate | Category | Classification | Usable for B-route send | Recommendation | Matches |
| --- | --- | --- | --- | --- | ---: |
| `frmLogin.StartWinServer / HttpServerLib.HttpService` | `localhost_http` | `requires_logged_in_process` | no | `do_not_pursue_before_in_process_adapter` | 31 |
| `HttpServerLib route/module framework` | `localhost_http` | `static_only_unknown` | no | `no_existing_ipc_first` | 8 |
| `PluginLightAppMulProcessCommunication NamedPipeServerStream` | `named_pipe` | `not_a_bridge` | no | `do_not_use` | 17 |
| `IMPP.Common.Helper.ProcessComm WM_COPYDATA helper` | `window_message` | `static_only_unknown` | no | `not_a_send_bridge` | 164 |
| `PluginManager / IMPlugin command surface` | `plugin` | `requires_logged_in_process` | no | `plugin_extension_not_first` | 2696 |
| `IMPlatformClient.Web / CEF helper` | `child_process` | `requires_logged_in_process` | no | `preview_only_not_send` | 343 |
| `INetwork / IOClientNetwork native stack` | `native_network` | `not_a_bridge` | no | `do_not_pursue_as_ipc` | 72 |
| `returned live-probe named pipe inventory` | `named_pipe` | `static_only_unknown` | no | `do_not_assume_isphere_api` | 49 |
| `local TcpListener candidates` | `tcp_listener` | `not_a_bridge` | no | `not_existing_ipc` | 145 |
## Verified facts
- `frmLogin.StartWinServer` constructs `HTTPServerLib.HttpService` on `0.0.0.0` and starts `HttpServer.Start` in a thread, but it depends on `IMPPManager.UserInfo` and `ServiceManager.UnifiedAuthentication` state.
- `HttpServerLib.dll` exposes route/module primitives such as `HttpService.RegisterModule`, `ServiceModule`, `ServiceRoute`, `RouteAttribute`, `OnGet`, and `OnPost`.
- Main client IL does not show a concrete `RegisterModule` call or send-message/send-file HTTP route registration.
- `PluginLightAppMulProcessCommunication` declares a `NamedPipeServerStream` field, but `StartLightAppMultProssCommunication` returns immediately.
- Returned live probe showed one remote TCP connection to port `10088` and a named pipe `\\.\\pipe\\zfpinject-msg-server`; neither proves an iSphere send API.
- `PluginManager`/`IMPlugin` evidence is UI/app-store/context-menu oriented; no local assembly-load plugin bridge or send route was confirmed.
## Candidate evidence
### frmLogin.StartWinServer / HttpServerLib.HttpService
Classification: `requires_logged_in_process`; usable for B-route send: `False`; matches: 31.
Reason: Static IL builds a local HTTP service from logged-in user/auth state, but no send route or active live local listener was confirmed.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:223` | `reference` | `.assembly extern HttpServerLib` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:888716` | `reference` | `RepairDatabase() cil managed` | `L888715: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:888818` | `string_literal` | `IL_00f8: ldstr "{0}/ifim/messageAuth\?version={1}&user={2}&resource"` | `L888715: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:888944` | `string_literal` | `IL_0287: ldstr "RepairDatabase.exe"` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889041` | `method_end` | `} // end of method frmLogin::RepairDatabase` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889166` | `callsite` | `IL_004d: call instance void IMPP.Client.frmLogin::RepairDatabase()` | `L889120: .method private hidebysig instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889253` | `signature_continuation` | `StartWinServer() cil managed` | `L889252: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889272` | `reference` | `class [HttpServerLib]HTTPServerLib.HttpService V_15,` | `L889252: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889518` | `callsite` | `IL_0276: call instance int32 IMPP.Client.frmLogin::GetFreePort(int32[])` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889551` | `string_literal` | `IL_02d3: ldstr "0.0.0.0"` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889554` | `constructor_call` | `IL_02dc: newobj instance void [HttpServerLib]HTTPServerLib.HttpService::.ctor(string,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889559` | `reference` | `IL_02e5: ldftn instance void [HttpServerLib]HTTPServerLib.HttpServer::Start()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889677` | `method_end` | `} // end of method frmLogin::StartWinServer` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889680` | `signature_continuation` | `GetFreePort(int32[] portArray) cil managed` | `L889679: .method private hidebysig instance int32` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889798` | `method_end` | `} // end of method frmLogin::GetFreePort` | `L889679: .method private hidebysig instance int32` |
| `runs\offline-broute-reverse\httpserverlib-reflection.json` | `reflection_metadata` | `HttpServerLib reflection exposes matching types/methods; see httpserverlib-reflection.json` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str10` | `binary_string` | `HttpServerLib.dll` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str12` | `binary_string` | `HTTPServerLib` | `` |
### HttpServerLib route/module framework
Classification: `static_only_unknown`; usable for B-route send: `False`; matches: 8.
Reason: HttpServerLib has ServiceModule/RouteAttribute primitives, but client IL does not show RegisterModule or send-message routes.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-broute-reverse\httpserverlib-reflection.json` | `reflection_metadata` | `HttpServerLib reflection exposes matching types/methods; see httpserverlib-reflection.json` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str21` | `binary_string` | `RouteAttribute` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str22` | `binary_string` | `RouteMethod` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str23` | `binary_string` | `ServiceModule` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str24` | `binary_string` | `ServiceRoute` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str292` | `binary_string` | `RegisterModule` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str302` | `binary_string` | `SearchRoute` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\HttpServerLib.dll#str303` | `binary_string` | `ExecuteRoute` | `` |
### PluginLightAppMulProcessCommunication NamedPipeServerStream
Classification: `not_a_bridge`; usable for B-route send: `False`; matches: 17.
Reason: A NamedPipeServerStream field exists, but StartLightAppMultProssCommunication has a one-instruction ret body.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9039` | `type_definition` | `.class private auto ansi beforefieldinit IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication` | `L9012: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9042` | `reference` | `.field private static class IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication _instance` | `L9012: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9043` | `reference` | `.field private class [System.Core]System.IO.Pipes.NamedPipeServerStream _pipeServer` | `L9012: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9052` | `method_end` | `} // end of method PluginLightAppMulProcessCommunication::.ctor` | `L9044: .method private hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9058` | `constructor_call` | `IL_0000: newobj instance void IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication::.ctor()` | `L9054: .method public hidebysig static void Init() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9059` | `reference` | `IL_0005: stsfld class IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication::_instance` | `L9054: .method public hidebysig static void Init() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9060` | `reference` | `IL_000a: ldsfld class IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication::_instance` | `L9054: .method public hidebysig static void Init() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9061` | `callsite` | `IL_000f: callvirt instance void IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication::StartLightAppMultProssCommunication()` | `L9054: .method public hidebysig static void Init() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9063` | `method_end` | `} // end of method PluginLightAppMulProcessCommunication::Init` | `L9054: .method public hidebysig static void Init() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9066` | `reference` | `StartLightAppMultProssCommunication() cil managed` | `L9065: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9071` | `method_end` | `} // end of method PluginLightAppMulProcessCommunication::StartLightAppMultProssCommunication` | `L9065: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9079` | `method_end` | `} // end of method PluginLightAppMulProcessCommunication::.cctor` | `L9073: .method private hidebysig specialname rtspecialname static` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:9081` | `reference` | `} // end of class IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication` | `L9073: .method private hidebysig specialname rtspecialname static` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:217833` | `callsite` | `IL_0071: call void IMPP.Client.Core.Plugins.MessageCenter.Classes.PluginLightAppMulProcessCommunication::Init()` | `L217793: .method private hidebysig specialname rtspecialname` |
| `runs\offline-real-client-window\full\zyl\Impp\IMPlatformClient.exe#str2852` | `binary_string` | `NamedPipeServerStream` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\IMPlatformClient.exe#str6922` | `binary_string` | `PluginLightAppMulProcessCommunication` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\IMPlatformClient.exe#str8523` | `binary_string` | `StartLightAppMultProssCommunication` | `` |
### IMPP.Common.Helper.ProcessComm WM_COPYDATA helper
Classification: `static_only_unknown`; usable for B-route send: `False`; matches: 164.
Reason: ProcessComm can send WM_COPYDATA to a foreground or titled window, but no receive handler or send-command contract is confirmed.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:271124` | `reference` | `native int GetForegroundWindow() cil managed preservesig` | `L271123: .method public hidebysig static pinvokeimpl("user32.dll" winapi)` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:278221` | `type_definition` | `.class sequential ansi sealed nested private beforefieldinit COPYDATASTRUCT` | `L278204: .method private hidebysig static object` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:278227` | `reference` | `} // end of class COPYDATASTRUCT` | `L278204: .method private hidebysig static object` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:279144` | `reference` | `.field private static literal int32 WM_COPYDATA = int32(0x0000004A)` | `L279026: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:282985` | `reference` | `int32 FindWindow(string lpClassName,` | `L282984: .method private hidebysig static pinvokeimpl("user32.dll" winapi)` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:317869` | `callsite` | `IL_3827: call native int IMPP.Client.utils.Utils::GetForegroundWindow()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:412102` | `callsite` | `IL_3613: call native int IMPP.Client.utils.Utils::GetForegroundWindow()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:543733` | `reference` | `.field public static literal int32 WM_COPYDATA = int32(0x0000004A)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:660716` | `reference` | `.locals init (valuetype IMPP.Client.Business.Logon.Native/COPYDATASTRUCT V_0,` | `L660712: .method public hidebysig static bool DefWndProc(valuetype [System.Windows.Forms]System.Windows.Forms.Message& m) cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:660731` | `reference` | `valuetype IMPP.Client.Business.Logon.Native/COPYDATASTRUCT V_15,` | `L660712: .method public hidebysig static bool DefWndProc(valuetype [System.Windows.Forms]System.Windows.Forms.Message& m) cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:660735` | `reference` | `valuetype IMPP.Client.Business.Logon.Native/COPYDATASTRUCT V_19,` | `L660712: .method public hidebysig static bool DefWndProc(valuetype [System.Windows.Forms]System.Windows.Forms.Message& m) cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:660746` | `reference` | `valuetype IMPP.Client.Business.Logon.Native/COPYDATASTRUCT V_30,` | `L660712: .method public hidebysig static bool DefWndProc(valuetype [System.Windows.Forms]System.Windows.Forms.Message& m) cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:661348` | `reference` | `IL_06b6: initobj IMPP.Client.Business.Logon.Native/COPYDATASTRUCT` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:661350` | `reference` | `IL_06bd: ldtoken IMPP.Client.Business.Logon.Native/COPYDATASTRUCT` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:661353` | `reference` | `IL_06cc: unbox.any IMPP.Client.Business.Logon.Native/COPYDATASTRUCT` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:661356` | `reference` | `IL_06d5: ldfld int32 IMPP.Client.Business.Logon.Native/COPYDATASTRUCT::cbData` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:661360` | `reference` | `IL_06e3: ldfld native int IMPP.Client.Business.Logon.Native/COPYDATASTRUCT::lpData` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:661446` | `reference` | `IL_07e8: stfld native int IMPP.Client.Business.Logon.Native/COPYDATASTRUCT::dwData` | `` |
### PluginManager / IMPlugin command surface
Classification: `requires_logged_in_process`; usable for B-route send: `False`; matches: 2696.
Reason: Plugin evidence is UI/app-store/context-menu oriented and depends on logged-in PluginManager state; no Assembly.Load/LoadFrom or send API bridge was confirmed.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1048` | `reference` | `.mresource public IMPP.Client.Core.Plugins.Manager.FrmAppStore.resources` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1051` | `reference` | `// 警告: 创建了托管资源文件 IMPP.Client.Core.Plugins.Manager.FrmAppStore.resources` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1063` | `reference` | `.mresource public IMPP.Client.Core.Plugins.Manager.WindowForm.FrmCefHomePage.resources` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1066` | `reference` | `// 警告: 创建了托管资源文件 IMPP.Client.Core.Plugins.Manager.WindowForm.FrmCefHomePage.resources` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:82622` | `callsite` | `IL_0156: call class IMPP.Client.Core.Plugins.Manager.PluginManager IMPP.Client.Core.Plugins.Manager.PluginManager::GetInstance()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:82623` | `callsite` | `IL_015b: callvirt instance class [mscorlib]System.Collections.Generic.List`1<class IMPP.Client.Core.Plugins.Manager.IMPlugin> IMPP.Client.Core.Plugins.Manager.PluginManager::GetPluginList()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:85884` | `callsite` | `IL_0157: call class IMPP.Client.Core.Plugins.Manager.PluginManager IMPP.Client.Core.Plugins.Manager.PluginManager::GetInstance()` | `L85783: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:85890` | `callsite` | `IL_0168: callvirt instance void IMPP.Client.Core.Plugins.Manager.PluginManager::add_PluginMessageNotify(class [IMPP.Interface]IMPP.Interface.PluginMessageEventHandler)` | `L85783: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:88002` | `callsite` | `IL_045d: call class IMPP.Client.Core.Plugins.Manager.PluginManager IMPP.Client.Core.Plugins.Manager.PluginManager::GetInstance()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:88007` | `callsite` | `IL_0470: callvirt instance void IMPP.Client.Core.Plugins.Manager.PluginManager::DoNotifyAction(string,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:90512` | `callsite` | `IL_0111: call void IMPP.Client.Core.Plugins.Manager.PluginUtils::LoadPluginContextMenu(class [System.Windows.Forms]System.Windows.Forms.ContextMenuStrip,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:90677` | `callsite` | `IL_016d: call void IMPP.Client.Core.Plugins.Manager.PluginUtils::LoadPluginContextMenu(class [System.Windows.Forms]System.Windows.Forms.ContextMenuStrip,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:91111` | `callsite` | `IL_00f8: call void IMPP.Client.Core.Plugins.Manager.PluginUtils::LoadPluginContextMenu(class [System.Windows.Forms]System.Windows.Forms.ContextMenuStrip,` | `L91000: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:91274` | `callsite` | `IL_00cb: call void IMPP.Client.Core.Plugins.Manager.PluginUtils::LoadPluginContextMenu(class [System.Windows.Forms]System.Windows.Forms.ContextMenuStrip,` | `L91188: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:105364` | `callsite` | `IL_03c1: callvirt instance string IMPP.Client.Core.Plugins.Manager.IMPlugin::get_Command()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:105401` | `callsite` | `IL_042d: call class IMPP.Client.Core.Plugins.Manager.PluginManager IMPP.Client.Core.Plugins.Manager.PluginManager::GetInstance()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:105402` | `callsite` | `IL_0432: callvirt instance class [mscorlib]System.Collections.Generic.List`1<class IMPP.Client.Core.Plugins.Manager.IMPlugin> IMPP.Client.Core.Plugins.Manager.PluginManager::GetPluginList()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:217227` | `reference` | `class IMPP.Client.Core.Plugins.Manager.PluginManager V_3,` | `L217219: .method public hidebysig newslot virtual final` |
### IMPlatformClient.Web / CEF helper
Classification: `requires_logged_in_process`; usable for B-route send: `False`; matches: 343.
Reason: The web helper/CEF surface exists, but static evidence points to UI/plugin browser support, not an external send/file API.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:278553` | `reference` | `.field public static class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `L278527: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300455` | `reference` | `IL_0428: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300465` | `string_literal` | `IL_0442: ldstr "NavigateTo"` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300492` | `callsite` | `IL_0477: call class [System.Core]System.Runtime.CompilerServices.CallSite`1<!0> class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.Compi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300493` | `reference` | `IL_047c: stsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300500` | `reference` | `IL_048f: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300501` | `reference` | `IL_0494: ldfld !0 class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300502` | `reference` | `IL_0499: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300504` | `callsite` | `IL_04a0: callvirt instance class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.WinCefBrowser IMPP.Client.Core.Plugins.Manager.WindowForm.frmPluginTabCtrl::get_CefBrowser()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:300506` | `callsite` | `IL_04a6: callvirt instance void class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.WinCefBrowser,object>::Invoke(!0,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:388726` | `reference` | `.field public static class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `L388681: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:413211` | `callsite` | `IL_0266: callvirt instance class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.WinCefBrowser IMPP.Client.Business.ISphereAssistant.frmExtendTabCtrl::get_CefBrowser()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:413215` | `callsite` | `IL_0277: callvirt instance void [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.WinCefBrowser::NavigateTo(string)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:414754` | `reference` | `IL_0b96: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:414764` | `string_literal` | `IL_0bb0: ldstr "NavigateTo"` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:414791` | `callsite` | `IL_0be5: call class [System.Core]System.Runtime.CompilerServices.CallSite`1<!0> class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.Compi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:414792` | `reference` | `IL_0bea: stsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:414799` | `reference` | `IL_0bfd: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1<class [mscorlib]System.Action`3<class [System.Core]System.Runtime.CompilerServices.CallSite,class [IMPP.Cef4CSharp]IMPP.Cef4CSharp.Win.Wi...` | `` |
### INetwork / IOClientNetwork native stack
Classification: `not_a_bridge`; usable for B-route send: `False`; matches: 72.
Reason: Native NETIO send/recv symbols are low-level transport, not a local command bridge into logged-in chat objects.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str249` | `binary_string` | `D:\work\me\sourcecode\app_project\Network_node\bin\release\INetwork.pdb` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str314` | `binary_string` | `INetwork.dll` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str317` | `binary_string` | `??0StreamClientBase@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str318` | `binary_string` | `??0StreamClientBase@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str319` | `binary_string` | `??0StreamClientChannel@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str320` | `binary_string` | `??0StreamClientChannel@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str321` | `binary_string` | `??0StreamClientFrameSink@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str322` | `binary_string` | `??0StreamClientFrameSink@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str323` | `binary_string` | `??0StreamClientMsg@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str324` | `binary_string` | `??0StreamClientMsg@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str325` | `binary_string` | `??0StreamClientMsgSink@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str326` | `binary_string` | `??0StreamClientMsgSink@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str327` | `binary_string` | `??0StreamClientNetEventSink@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str328` | `binary_string` | `??0StreamClientNetEventSink@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str329` | `binary_string` | `??0StreamClientRecverEventSink@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str330` | `binary_string` | `??0StreamClientRecverEventSink@@QAE@XZ` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str331` | `binary_string` | `??0StreamClientSenderEventSink@@QAE@ABV0@@Z` | `` |
| `runs\offline-real-client-window\full\zyl\Impp\INetwork.dll#str332` | `binary_string` | `??0StreamClientSenderEventSink@@QAE@XZ` | `` |
### returned live-probe named pipe inventory
Classification: `static_only_unknown`; usable for B-route send: `False`; matches: 49.
Reason: Live probe saw \\.\\pipe\\zfpinject-msg-server, but no matching iSphere static string or send contract was found in the configured samples.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:905746` | `reference` | `frmMain_OnReConnectionSucceed(object sender,` | `L905745: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:905814` | `reference` | `IL_0081: ldftn void IMPP.Client.frmMain::'<frmMain_OnReConnectionSucceed>b__4'()` | `L905745: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:905828` | `method_end` | `} // end of method frmMain::frmMain_OnReConnectionSucceed` | `L905745: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:919019` | `reference` | `IL_006e: ldftn instance void IMPP.Client.frmMain::frmMain_OnReConnectionSucceed(object,` | `L918967: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:919023` | `callsite` | `IL_0079: callvirt instance void [smack]com.vision.smack.XMPPConnection::add_OnReConnectionSucceed(class [mscorlib]System.EventHandler)` | `L918967: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:928079` | `method_header` | `.method private hidebysig static void '<frmMain_OnReConnectionSucceed>b__4'() cil managed` | `L928079: .method private hidebysig static void '<frmMain_OnReConnectionSucceed>b__4'() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:928087` | `method_end` | `} // end of method frmMain::'<frmMain_OnReConnectionSucceed>b__4'` | `L928079: .method private hidebysig static void '<frmMain_OnReConnectionSucceed>b__4'() cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1126866` | `reference` | `IL_00e2: ldftn instance void IMPP.Client.Business.ReConnection.WindowForm.FrmReConnection::OnReConnectionSucceed(object,` | `L1126773: .method private hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1126870` | `callsite` | `IL_00ed: callvirt instance void [smack]com.vision.smack.XMPPConnection::add_OnReConnectionSucceed(class [mscorlib]System.EventHandler)` | `L1126773: .method private hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1127782` | `reference` | `OnReConnectionSucceed(object sender,` | `L1127781: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1127841` | `reference` | `IL_0069: ldftn instance void IMPP.Client.Business.ReConnection.WindowForm.FrmReConnection::'<OnReConnectionSucceed>b__0'()` | `L1127781: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1127855` | `method_end` | `} // end of method FrmReConnection::OnReConnectionSucceed` | `L1127781: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1127979` | `reference` | `'<OnReConnectionSucceed>b__0'() cil managed` | `L1127978: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1127987` | `method_end` | `} // end of method FrmReConnection::'<OnReConnectionSucceed>b__0'` | `L1127978: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ISphere.exe.il:33510` | `reference` | `IL_00e2: ldftn instance void IMPP.ISphere.IMPPManage.FrmReConnection::OnReConnectionSucceed(object,` | `L33417: .method private hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ISphere.exe.il:33514` | `callsite` | `IL_00ed: callvirt instance void [smack]com.vision.smack.XMPPConnection::add_OnReConnectionSucceed(class [mscorlib]System.EventHandler)` | `L33417: .method private hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ISphere.exe.il:34155` | `reference` | `OnReConnectionSucceed(object sender,` | `L34154: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ISphere.exe.il:34182` | `reference` | `IL_0031: ldftn instance void IMPP.ISphere.IMPPManage.FrmReConnection::'<OnReConnectionSucceed>b__0'()` | `L34154: .method private hidebysig instance void` |
### local TcpListener candidates
Classification: `not_a_bridge`; usable for B-route send: `False`; matches: 145.
Reason: TcpListener evidence is tied to TCP file manage or smack socks5, not a command API for text/file send. Returned live probe had only one remote XMPP connection.
| Source | Kind | Snippet | Context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:889833` | `callsite` | `IL_003c: callvirt instance class [System]System.Net.IPEndPoint[] [System]System.Net.NetworkInformation.IPGlobalProperties::GetActiveTcpListeners()` | `L889800: .method private hidebysig instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1117108` | `reference` | `.field private static class [System]System.Net.Sockets.TcpListener _listener` | `L1117034: .method public hidebysig instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1118022` | `reference` | `IL_0058: ldsfld class [System]System.Net.Sockets.TcpListener IMPP.Client.Business.TCPFileManage.TCPFileManager::_listener` | `L1117985: .method public hidebysig static class [System]System.Net.IPEndPoint` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1118096` | `reference` | `IL_011e: ldsfld class [System]System.Net.Sockets.TcpListener IMPP.Client.Business.TCPFileManage.TCPFileManager::_listener` | `L1117985: .method public hidebysig static class [System]System.Net.IPEndPoint` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1118097` | `callsite` | `IL_0123: callvirt instance class [System]System.Net.EndPoint [System]System.Net.Sockets.TcpListener::get_LocalEndpoint()` | `L1117985: .method public hidebysig static class [System]System.Net.IPEndPoint` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1118817` | `callsite` | `IL_0091: callvirt instance class [System]System.Net.IPEndPoint[] [System]System.Net.NetworkInformation.IPGlobalProperties::GetActiveTcpListeners()` | `L1118746: .method public hidebysig static int32 FindFreeTCPPort([opt] int32 startPort) cil managed` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1119636` | `reference` | `IL_0009: stsfld class [System]System.Net.Sockets.TcpListener IMPP.Client.Business.TCPFileManage.TCPFileManager::_listener` | `L1119622: .method private hidebysig specialname rtspecialname static` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64917` | `reference` | `.field private class com.vision.smack.socks5.TCPSocks5Server '<Server>k__BackingField'` | `L64879: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64926` | `signature_continuation` | `instance void .ctor(class com.vision.smack.socks5.TCPSocks5Server server,` | `L64925: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64935` | `callsite` | `IL_0008: call instance void com.vision.smack.socks5.TCPSocks5Connection::set_Server(class com.vision.smack.socks5.TCPSocks5Server)` | `L64925: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64942` | `method_header` | `.method private hidebysig specialname instance class com.vision.smack.socks5.TCPSocks5Server` | `L64942: .method private hidebysig specialname instance class com.vision.smack.socks5.TCPSocks5Server` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64949` | `reference` | `IL_0001: ldfld class com.vision.smack.socks5.TCPSocks5Server com.vision.smack.socks5.TCPSocks5Connection::'<Server>k__BackingField'` | `L64942: .method private hidebysig specialname instance class com.vision.smack.socks5.TCPSocks5Server` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64954` | `reference` | `set_Server(class com.vision.smack.socks5.TCPSocks5Server 'value') cil managed` | `L64953: .method private hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:64961` | `reference` | `IL_0002: stfld class com.vision.smack.socks5.TCPSocks5Server com.vision.smack.socks5.TCPSocks5Connection::'<Server>k__BackingField'` | `L64953: .method private hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:65018` | `callsite` | `IL_0001: call instance class com.vision.smack.socks5.TCPSocks5Server com.vision.smack.socks5.TCPSocks5Connection::get_Server()` | `L65012: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:65019` | `callsite` | `IL_0006: callvirt instance bool com.vision.smack.socks5.TCPSocks5Server::get_IsStarting()` | `L65012: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:65027` | `callsite` | `IL_0016: call instance class com.vision.smack.socks5.TCPSocks5Server com.vision.smack.socks5.TCPSocks5Connection::get_Server()` | `L65012: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:65028` | `callsite` | `IL_001b: callvirt instance bool com.vision.smack.socks5.TCPSocks5Server::get_RequireValidate()` | `L65012: .method private hidebysig instance void` |
## Decision
No existing IPC/plugin/local HTTP bridge is confirmed usable for B-route text send or file send. The next B-route branch should be `in_process_adapter_research`. Until that exists, an external sidecar can only do preview/probe work, while A-route/RPA remains backup-only.
## Next work
1. Update the B-route next plan and capability source matrix with `in_process_adapter_research` as the offline decision.
2. Create a separate plan before implementing any in-process adapter work.
3. Do not promote RPA beyond backup in this branch.

View File

@@ -0,0 +1,230 @@
# B-Route File Send Static Map
Date: 2026-07-12
## Scope
This report uses offline IL only. It does not upload files, send file messages, replay traffic, inject into a process, or mutate the original iSphere binaries.
Machine-readable evidence: `runs/offline-broute-reverse/file-send-map.json`
## Sources
| Source | Role | Exists | Lines | Size bytes |
| --- | --- | --- | ---: | ---: |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il` | main client IL; UC_FileSend, OffLineFileSend, MessageScheduling | yes | 1360035 | 74148460 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il` | file transfer service implementation | yes | 3351 | 207786 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il` | file transfer contracts, parameters, and result DTOs | yes | 1641 | 93585 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Interface.dll.il` | transport/event interface contracts | yes | 4429 | 227335 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il` | Chat.sendFileMessage and XMPP message construction | yes | 87425 | 5019807 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\TcpFileTransfer.dll.il` | native TCP transfer candidate; IL may be unavailable | yes | 5 | 241 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Model.dll.il` | chat record models if extracted | no | 0 | |
## Target classification
| Target | Phase | Intent | Classification | Matches |
| --- | --- | --- | --- | ---: |
| `IMPP.Client.OffLineFileSend.sendFile` | `upload` | offline file upload orchestration from UI file sender | `confirmed_signature` | 3 |
| `IMPP.Client.OffLineFileSend.trans_UploadCompleted` | `upload_to_message_boundary` | upload-completed handler that converts FileUploadResult.FileID into a chat file message | `confirmed_signature` | 3 |
| `IMPP.Service.BLL.FileTransfer.FileUpload` | `upload` | synchronous service upload entry returning FileUploadResult | `confirmed_signature` | 17 |
| `IMPP.Service.BLL.FileTransfer.AsynFileUpload` | `upload` | asynchronous upload queue entry | `confirmed_signature` | 3 |
| `FileUploadPara` | `upload_parameters` | host/port/domain/local file path/cache/timeout/upload flags | `confirmed_signature` | 66 |
| `FileUploadResult.FileID` | `upload_result` | server-side file id and URL returned by upload | `confirmed_signature` | 75 |
| `MessageScheduling.SendFileMessage` | `message` | scheduled/file-message finalization path using RosterContactsArgs and FileTransferArgs | `confirmed_signature` | 3 |
| `Chat.sendFileMessage` | `message` | smack API that sends the XMPP file message after upload metadata is known | `confirmed_signature` | 8 |
| `Required values: path id size target jid` | `requirements` | static evidence for local file path, remote folder/file id, file size/name, target JID/chat, and server auth | `confirmed_signature` | 550 |
## Upload step vs chat-message step
| Step | Static evidence | Business meaning |
| --- | --- | --- |
| 1. Upload | `IMPP.Client.OffLineFileSend.sendFile` constructs `FileUploadPara` and calls `IFileTransfer.FileUpload`; `IMPP.Service.BLL.FileTransfer.FileUpload` delegates to `SyncFileTransfer` and private `Upload`. | The local file must be uploaded to the HTTP file service first. |
| 2. Upload result | `FileUploadResult.FileID` is read; upload notification calls `IMPP.Client.OffLineFileSend.trans_UploadCompleted(fileId)`. | The server-issued file id becomes the durable reference. |
| 3. Chat file message | `trans_UploadCompleted` and `MessageScheduling.SendFileMessage` build `OFFLINE_FILE_TRANSFER;...` payloads and call `Chat.sendFileMessage`. | The chat send step publishes metadata/reference, not raw file bytes. |
Conclusion: file send is upload-first, chat-message-second. A direct chat message is insufficient without a valid uploaded file id or remote folder reference.
## Required values
| Value | Status | Evidence |
| --- | --- | --- |
| local file path | `required_confirmed` | OffLineFileSend constructor receives localFilePath; sendFile uses get__localFilePath; MessageScheduling.SendFileMessage uses FileTransferArgs.get_LocalFilePath. |
| server file id / remote folder | `required_confirmed` | Upload returns FileUploadResult.FileID; trans_UploadCompleted(fileId) passes it to Chat.sendFileMessage and Packet.set_id. MessageScheduling path uses FileTransferArgs.get_RemoteFolder. |
| file name and file size | `required_confirmed` | SendFileMessage builds OFFLINE_FILE_TRANSFER payload from FileInfo/FileSystemInfo name and length; OffLineFileSend keeps _fileSize. |
| target JID / chat object | `required_confirmed` | UC_FileSend and MessageScheduling both require a live smack Chat; scheduled path derives it from RosterContactsArgs.get_RosterJid + UtilsChat.GetChat. |
| HTTP file server and auth | `required_confirmed` | FileUploadPara construction uses Config.ServerInfo.HTTPFileServer IP/Port/domain; service upload builds /FS/auth/resource/upload URL with UnifiedAuthentication token/app key/client secret/time/user id. |
| file hash | `not_confirmed_in_file_send_path` | No file-transfer-path MD5/SHA/checksum requirement was confirmed in the configured IL. MD5 hits belong to image/RTF or auth digest areas, not the offline file-send path. |
## Evidence excerpts
### IMPP.Client.OffLineFileSend.sendFile
Classification: `confirmed_signature`; phase: `upload`; matches: 3.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826149` | `signature_continuation` | `sendFile() cil managed` | `L826148: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826572` | `method_end` | `} // end of method OffLineFileSend::sendFile` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:824164` | `callsite` | `IL_026b: callvirt instance void IMPP.Client.OffLineFileSend::sendFile()` | `` |
### IMPP.Client.OffLineFileSend.trans_UploadCompleted
Classification: `confirmed_signature`; phase: `upload_to_message_boundary`; matches: 3.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826888` | `signature_continuation` | `trans_UploadCompleted(string fileId) cil managed` | `L826887: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:827241` | `method_end` | `} // end of method OffLineFileSend::trans_UploadCompleted` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826670` | `callsite` | `IL_0104: call instance void IMPP.Client.OffLineFileSend::trans_UploadCompleted(string)` | `L826574: .method private hidebysig instance void` |
### IMPP.Service.BLL.FileTransfer.FileUpload
Classification: `confirmed_signature`; phase: `upload`; matches: 17.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:851` | `signature_continuation` | `FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L849: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:870` | `method_end` | `} // end of method FileTransfer::FileUpload` | `L849: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:873` | `signature_continuation` | `instance void AsynFileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L872: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:164` | `method_end` | `} // end of method IFileTransfer::FileUpload` | `L160: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:512182` | `callsite` | `IL_01d9: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608800` | `callsite` | `IL_00b2: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L608722: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608921` | `callsite` | `IL_00c5: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L608836: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:679721` | `callsite` | `IL_00bf: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L679642: .method public hidebysig static void UploadGroupImage(string jid,` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:699952` | `callsite` | `IL_024c: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:738959` | `callsite` | `IL_00bf: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L738869: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:739560` | `callsite` | `IL_00bf: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L739470: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:790010` | `callsite` | `IL_018b: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826414` | `callsite` | `IL_02b6: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:2852` | `reference` | `} // end of property FileTransfer::FileUploadMaxCount` | `L2825: .method family hidebysig newslot virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:2861` | `reference` | `} // end of property FileTransfer::FileUploadCount` | `L2825: .method family hidebysig newslot virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:186` | `reference` | `} // end of property IFileTransfer::FileUploadMaxCount` | `L177: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:205` | `reference` | `} // end of property IFileTransfer::FileUploadCount` | `L177: .method public hidebysig newslot abstract virtual` |
### IMPP.Service.BLL.FileTransfer.AsynFileUpload
Classification: `confirmed_signature`; phase: `upload`; matches: 3.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:873` | `signature_continuation` | `instance void AsynFileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L872: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:883` | `method_end` | `} // end of method FileTransfer::AsynFileUpload` | `L872: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:169` | `method_end` | `} // end of method IFileTransfer::AsynFileUpload` | `L166: .method public hidebysig newslot abstract virtual` |
### FileUploadPara
Classification: `confirmed_signature`; phase: `upload_parameters`; matches: 66.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:851` | `signature_continuation` | `FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L849: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:873` | `signature_continuation` | `instance void AsynFileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L872: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:162` | `signature_continuation` | `FileUpload(class IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L160: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:167` | `signature_continuation` | `instance void AsynFileUpload(class IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara para) cil managed` | `L166: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1139` | `type_definition` | `.class public auto ansi serializable beforefieldinit IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara` | `L1086: .method family hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1155` | `method_end` | `} // end of method FileUploadPara::get_IsImg` | `L1146: .method public hidebysig specialname instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1167` | `method_end` | `} // end of method FileUploadPara::set_IsImg` | `L1157: .method private hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1178` | `method_end` | `} // end of method FileUploadPara::get_IsPublic` | `L1169: .method public hidebysig specialname instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1190` | `method_end` | `} // end of method FileUploadPara::set_IsPublic` | `L1180: .method public hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1234` | `method_end` | `} // end of method FileUploadPara::.ctor` | `L1192: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1263` | `method_end` | `} // end of method FileUploadPara::.ctor` | `L1236: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:512182` | `callsite` | `IL_01d9: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:512459` | `constructor_call` | `IL_04a3: newobj instance void [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara::.ctor(string,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608787` | `constructor_call` | `IL_0092: newobj instance void [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara::.ctor(string,` | `L608722: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608795` | `callsite` | `IL_009c: callvirt instance void [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara::set_IsPublic(bool)` | `L608722: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608800` | `callsite` | `IL_00b2: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L608722: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608908` | `constructor_call` | `IL_00a5: newobj instance void [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara::.ctor(string,` | `L608836: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608916` | `callsite` | `IL_00af: callvirt instance void [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara::set_IsPublic(bool)` | `L608836: .method private hidebysig instance void` |
### FileUploadResult.FileID
Classification: `confirmed_signature`; phase: `upload_result`; matches: 75.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.Service.dll.il:982` | `method_header` | `.method private hidebysig instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult` | `L982: .method private hidebysig instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1277` | `type_definition` | `.class public auto ansi serializable beforefieldinit IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult` | `L1236: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1307` | `method_end` | `} // end of method FileUploadResult::.ctor` | `L1284: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1318` | `method_end` | `} // end of method FileUploadResult::get_FileID` | `L1309: .method public hidebysig specialname instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1330` | `method_end` | `} // end of method FileUploadResult::set_FileID` | `L1320: .method private hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1341` | `method_end` | `} // end of method FileUploadResult::get_Url` | `L1332: .method public hidebysig specialname instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:1353` | `method_end` | `} // end of method FileUploadResult::set_Url` | `L1343: .method private hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:512182` | `callsite` | `IL_01d9: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:512315` | `callsite` | `IL_0322: callvirt instance string [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult::get_FileID()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608800` | `callsite` | `IL_00b2: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L608722: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608819` | `callsite` | `IL_00e0: callvirt instance string [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult::get_FileID()` | `L608722: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608921` | `callsite` | `IL_00c5: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L608836: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:608940` | `callsite` | `IL_00f3: callvirt instance string [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult::get_FileID()` | `L608836: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:679721` | `callsite` | `IL_00bf: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L679642: .method public hidebysig static void UploadGroupImage(string jid,` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:679747` | `callsite` | `IL_00f4: callvirt instance string [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult::get_FileID()` | `L679642: .method public hidebysig static void UploadGroupImage(string jid,` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:699952` | `callsite` | `IL_024c: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:699993` | `callsite` | `IL_02bf: callvirt instance string [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult::get_FileID()` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:738959` | `callsite` | `IL_00bf: callvirt instance class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadResult [IMPP.ServiceBase]IMPP.ServiceBase.Interface.IFileTransfer::FileUpload(class [IMPP.ServiceBase]IMPP.ServiceBase.ModelBase.FileServiceBase.FileUploadPara)` | `L738869: .method private hidebysig instance void` |
### MessageScheduling.SendFileMessage
Classification: `confirmed_signature`; phase: `message`; matches: 3.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1153803` | `signature_continuation` | `SendFileMessage(class IMPP.Client.Business.RosterContacts.RosterContactsArgs rosterArgs,` | `L1153802: .method public hidebysig static class [smack]com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1154125` | `method_end` | `} // end of method MessageScheduling::SendFileMessage` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:728711` | `callsite` | `IL_01fb: call class [smack]com.vision.smack.packet.XmppMessage IMPP.Client.Core.MessageEngine.MessageScheduling::SendFileMessage(class IMPP.Client.Business.RosterContacts.RosterContactsArgs,` | `` |
### Chat.sendFileMessage
Classification: `confirmed_signature`; phase: `message`; matches: 8.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1235` | `signature_continuation` | `sendFileMessage(string p_message,` | `L1233: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1244` | `method_end` | `} // end of method Chat::sendFileMessage` | `L1233: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:21442` | `signature_continuation` | `sendFileMessage(string p_message,` | `L21441: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:21490` | `method_end` | `} // end of method P2PChat::sendFileMessage` | `L21441: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30070` | `signature_continuation` | `sendFileMessage(string p_message,` | `L30069: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30119` | `method_end` | `} // end of method ChatRoom::sendFileMessage` | `L30069: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:827225` | `callsite` | `IL_034d: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::sendFileMessage(string,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1153922` | `callsite` | `IL_00f1: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::sendFileMessage(string,` | `L1153802: .method public hidebysig static class [smack]com.vision.smack.packet.XmppMessage` |
### Required values: path id size target jid
Classification: `confirmed_signature`; phase: `requirements`; matches: 550.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:611789` | `method_end` | `} // end of method RosterContactsArgs::get_RosterJid` | `L611780: .method public hidebysig specialname instance class [smack]com.vision.smack.Jid` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:670956` | `type_definition` | `.class public auto ansi beforefieldinit IMPP.Client.Business.Authentication.UnifiedAuthenticationCenter` | `L670876: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:671260` | `method_end` | `} // end of method UnifiedAuthenticationCenter::Authentication` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:671331` | `method_end` | `} // end of method UnifiedAuthenticationCenter::GetServerTimeSpan` | `L671262: .method public hidebysig instance valuetype [mscorlib]System.TimeSpan` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:671390` | `method_end` | `} // end of method UnifiedAuthenticationCenter::ConvertStringToDateTime` | `L671333: .method private hidebysig instance valuetype [mscorlib]System.DateTime` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:671406` | `method_end` | `} // end of method UnifiedAuthenticationCenter::InitServiceManager` | `L671392: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:671416` | `method_end` | `} // end of method UnifiedAuthenticationCenter::.ctor` | `L671408: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:723604` | `method_end` | `} // end of method FileTransferArgs::get_LocalFilePath` | `L723595: .method public hidebysig specialname instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:723650` | `method_end` | `} // end of method FileTransferArgs::get_RemoteFolder` | `L723641: .method public hidebysig specialname instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:723876` | `method_end` | `} // end of method RosterContactsGroupArgs::get_RosterJid` | `L723867: .method public hidebysig specialname instance class [smack]com.vision.smack.Jid` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:767951` | `method_end` | `} // end of method ServerInfo::set_HTTPFileServer` | `L767941: .method public hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:767962` | `method_end` | `} // end of method ServerInfo::get_HTTPFileServer` | `L767953: .method public hidebysig specialname instance class IMPP.Client.Server` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:768272` | `method_end` | `} // end of method BaseConfig::UnifiedAuthenticationAddress` | `L768241: .method public hidebysig instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826017` | `method_end` | `} // end of method OffLineFileSend::get__localFilePath` | `L826008: .method private hidebysig specialname instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:826109` | `method_end` | `} // end of method OffLineFileSend::get__fileSize` | `L826100: .method private hidebysig specialname instance int64` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1339551` | `method_end` | `} // end of method OffLineFileRecv::get__fileSize` | `L1339542: .method private hidebysig specialname instance int64` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPP.ServiceBase.dll.il:477` | `method_end` | `} // end of method FileTransferParaBase::get_LocalFilePath` | `L468: .method public hidebysig specialname instance string` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:13688` | `callsite` | `IL_0179: callvirt instance class [smack]com.vision.smack.Chat [smack]com.vision.smack.ChatManager::GetChat(class [smack]com.vision.smack.XMPPConnection,` | `` |
## Inference
- High confidence: upload and file-message finalization are separate steps.
- High confidence: `FileUploadPara` needs HTTP file server host/port/domain plus local file path and upload options.
- High confidence: `Chat.sendFileMessage` produces the XMPP file-message envelope after file metadata is available.
- Medium confidence: B-route file send must either call the same logged-in service/runtime or reproduce the authenticated HTTP upload plus XMPP file-message sequence.
- Low confidence until bridge evidence: an external sidecar can obtain the same service/auth/chat objects without being inside the logged-in client process.
## Decision
Offline static evidence supports B-route research, but file sending needs more than one method call: upload first, then chat file-message finalization. The next blocker is reachability: whether an IPC/plugin/in-process bridge can access the logged-in `IFileTransfer` service and smack `Chat` object.
## Next work
1. Extract bridge/IPC/plugin candidates before coding any file-send adapter.
2. If a bridge exists, prototype non-mutating discovery first: service availability, chat object lookup, and upload endpoint metadata only.
3. Keep A-route/RPA backup-only.

View File

@@ -0,0 +1,149 @@
# B-Route Text Send Static Map
Date: 2026-07-12
## Scope
This report uses offline IL only. It does not log in, send messages, replay traffic, inject into a process, or mutate the original iSphere binaries.
Machine-readable evidence: `runs/offline-broute-reverse/text-send-map.json`
## Sources
| Source | Role | Exists | Lines | Size bytes |
| --- | --- | --- | ---: | ---: |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il` | main managed client IL; chat UI, AppContextManager, scheduling call sites | yes | 1360035 | 74148460 |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il` | XMPP/smack managed IL; Chat and XMPPConnection implementation | yes | 87425 | 5019807 |
## Target classification
| Target | Intent | Classification | Matches |
| --- | --- | --- | ---: |
| `AppContextManager.SendTxtMessageByJid` | high-level text send entry exposed by AppContextManager | `confirmed_signature` | 4 |
| `MessageScheduling.SendChatMessage` | message scheduling text/chat send helper | `confirmed_signature` | 6 |
| `Chat.SendMessage` | smack Chat send API used by chat/UI code | `confirmed_signature` | 16 |
| `XMPPConnection.send` | lowest-level XMPP connection send primitive | `confirmed_signature` | 105 |
| `MessageCenter send-related callbacks` | MessageCenter or IMPPManager message-center callbacks touching send/message flow | `confirmed_signature` | 962 |
## Verified facts
- `AppContextManager.SendTxtMessageByJid` appears in the offline client IL and is not just a UIA/RPA artifact.
- `MessageScheduling.SendChatMessage` appears in the offline client IL and has call-site evidence around chat-message flow.
- `Chat.SendMessage` / `Chat.sendMessage` appears as a smack chat API used by client code.
- `XMPPConnection.send` appears as a lower-level connection send primitive in offline IL.
- `MessageCenter send-related callbacks` appear as MessageCenter/GetMessageCenter references, but offline static evidence alone does not prove an external bridge can call them.
## Evidence excerpts
### AppContextManager.SendTxtMessageByJid
Classification: `confirmed_signature`; matches: 4.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:585987` | `signature_continuation` | `instance void SendTxtMessageByJid(string jid,` | `L585986: .method public hidebysig newslot virtual final` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:586197` | `method_end` | `} // end of method AppContextManager::SendTxtMessageByJid` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:542112` | `string_literal` | `IL_09bf: ldstr "SendTxtMessageByJid"` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1358068` | `string_literal` | `IL_0c3f: ldstr "SendTxtMessageByJid"` | `` |
### MessageScheduling.SendChatMessage
Classification: `confirmed_signature`; matches: 6.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1153800` | `method_end` | `} // end of method MessageScheduling::SendChatMessage` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1154225` | `method_end` | `} // end of method MessageScheduling::SendChatMessage` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:728720` | `callsite` | `IL_0213: call class [smack]com.vision.smack.packet.XmppMessage IMPP.Client.Core.MessageEngine.MessageScheduling::SendChatMessage(class IMPP.Client.Business.RosterContacts.RosterContactsArgs,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1154187` | `callsite` | `IL_006e: call class [smack]com.vision.smack.packet.XmppMessage IMPP.Client.Core.MessageEngine.MessageScheduling::SendChatMessage(class [smack]com.vision.smack.Chat,` | `L1154127: .method public hidebysig static class [smack]com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1153541` | `reference` | `SendChatMessage(class [smack]com.vision.smack.Chat chat,` | `L1153540: .method public hidebysig static class [smack]com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1154128` | `reference` | `SendChatMessage(class IMPP.Client.Business.RosterContacts.RosterContactsArgs rosterArgs,` | `L1154127: .method public hidebysig static class [smack]com.vision.smack.packet.XmppMessage` |
### Chat.SendMessage
Classification: `confirmed_signature`; matches: 16.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1220` | `method_end` | `} // end of method Chat::sendMessage` | `L1217: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1231` | `method_end` | `} // end of method Chat::sendMessage` | `L1222: .method public hidebysig newslot abstract virtual` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1425` | `method_end` | `} // end of method Chat::SendMessage` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1266` | `callsite` | `IL_0021: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L1246: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:21393` | `callsite` | `IL_0047: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L21362: .method public hidebysig virtual instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:21431` | `callsite` | `IL_002b: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L21398: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:21482` | `callsite` | `IL_003a: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L21441: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:29958` | `callsite` | `IL_0047: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L29927: .method public hidebysig virtual instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30063` | `callsite` | `IL_0064: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L30012: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30115` | `callsite` | `IL_0056: call instance class com.vision.smack.packet.XmppMessage com.vision.smack.Chat::SendMessage(class com.vision.smack.packet.XmppMessage)` | `L30069: .method public hidebysig virtual instance class com.vision.smack.packet.XmppMessage` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:299997` | `callsite` | `IL_0096: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::SendMessage(class [smack]com.vision.smack.packet.XmppMessage)` | `L299937: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:412929` | `callsite` | `IL_00d2: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::SendMessage(class [smack]com.vision.smack.packet.XmppMessage)` | `L412858: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:430535` | `callsite` | `IL_0357: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::SendMessage(class [smack]com.vision.smack.packet.XmppMessage)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:739211` | `callsite` | `IL_012c: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::sendMessage(string,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:739789` | `callsite` | `IL_012c: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::sendMessage(string,` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:1153743` | `callsite` | `IL_01d0: callvirt instance class [smack]com.vision.smack.packet.XmppMessage [smack]com.vision.smack.Chat::SendMessage(class [smack]com.vision.smack.packet.XmppMessage)` | `` |
### XMPPConnection.send
Classification: `confirmed_signature`; matches: 105.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:86319` | `method_end` | `} // end of method XMPPConnection::send` | `L86263: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:1378` | `callsite` | `IL_00bc: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:17710` | `callsite` | `IL_004a: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L17649: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:23187` | `callsite` | `IL_004b: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L23150: .method public hidebysig instance class com.vision.smack.disco.DiscoInfoResponse` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:23248` | `callsite` | `IL_002c: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L23220: .method public hidebysig virtual instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:23962` | `callsite` | `IL_0110: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:26786` | `callsite` | `IL_004e: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L26752: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:26855` | `callsite` | `IL_008b: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L26790: .method public hidebysig instance class [mscorlib]System.Collections.Generic.IList`1<class com.vision.smack.muc.BookMarkConfenerce>` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:26980` | `callsite` | `IL_008a: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L26919: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:29230` | `callsite` | `IL_00a2: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L29148: .method public hidebysig instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:29470` | `callsite` | `IL_007d: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L29404: .method public hidebysig instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:29690` | `callsite` | `IL_0053: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L29646: .method public hidebysig instance bool` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:29841` | `callsite` | `IL_00b2: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L29766: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:29918` | `callsite` | `IL_0033: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L29894: .method public hidebysig virtual instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30152` | `callsite` | `IL_003c: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L30121: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30178` | `callsite` | `IL_0023: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L30159: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30221` | `callsite` | `IL_0046: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L30182: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\smack.dll.il:30268` | `callsite` | `IL_004d: callvirt instance void com.vision.smack.XMPPConnection::send(string)` | `L30225: .method public hidebysig instance void` |
### MessageCenter send-related callbacks
Classification: `confirmed_signature`; matches: 962.
| Source line | Kind | Snippet | Method context |
| --- | --- | --- | --- |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:74199` | `method_end` | `} // end of method MessageCenter::add_OnMessageSended` | `L74135: .method public hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:74265` | `method_end` | `} // end of method MessageCenter::remove_OnMessageSended` | `L74201: .method public hidebysig specialname instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:75658` | `method_end` | `} // end of method MessageCenter::pChat_OnMessageSended` | `L75587: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:82645` | `method_end` | `} // end of method MessageCenter::SendPluginsOffLineMessage` | `` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:206380` | `method_end` | `} // end of method AVRoomMessageCenter::Send` | `L206369: .method public hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:945209` | `method_header` | `.method public hidebysig instance class IMPP.Client.control.MessageCenter` | `L945209: .method public hidebysig instance class IMPP.Client.control.MessageCenter` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:945695` | `method_header` | `.method public hidebysig specialname instance class IMPP.Client.control.MessageCenter` | `L945695: .method public hidebysig specialname instance class IMPP.Client.control.MessageCenter` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72786` | `callsite` | `IL_0005: callvirt instance class IMPP.Client.control.MessageCenter IMPP.Client.IMPPManager::GetMessageCenter()` | `L72779: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72787` | `callsite` | `IL_000a: callvirt instance int32 IMPP.Client.control.MessageCenter::GetChatMessageCount()` | `L72779: .method private hidebysig instance void` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72814` | `callsite` | `IL_000b: callvirt instance class IMPP.Client.control.MessageCenter IMPP.Client.IMPPManager::GetMessageCenter()` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72820` | `callsite` | `IL_001c: callvirt instance void IMPP.Client.control.MessageCenter::add_OnMessageArrived(class IMPP.Client.control.MessageCenter/MessageArrivedHandler)` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72822` | `callsite` | `IL_0026: callvirt instance class IMPP.Client.control.MessageCenter IMPP.Client.IMPPManager::GetMessageCenter()` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72827` | `callsite` | `IL_0037: callvirt instance void IMPP.Client.control.MessageCenter::add_OnMessageConsumed(class IMPP.Client.control.MessageCenter/MessageConsumedHandler)` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72829` | `callsite` | `IL_0041: callvirt instance class IMPP.Client.control.MessageCenter IMPP.Client.IMPPManager::GetMessageCenter()` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72835` | `callsite` | `IL_0052: callvirt instance void IMPP.Client.control.MessageCenter::add_UpdateUnreadMessageCount(class IMPP.Client.control.MessageCenter/UpdateUnreadMessageCountHandler)` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72837` | `callsite` | `IL_005c: callvirt instance class IMPP.Client.control.MessageCenter IMPP.Client.IMPPManager::GetMessageCenter()` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72842` | `callsite` | `IL_006d: callvirt instance void IMPP.Client.control.MessageCenter::add_TcpFileToOffline(class [mscorlib]System.Action`1<class [smack]com.vision.smack.packet.XmppMessage>)` | `L72806: .method public hidebysig specialname rtspecialname` |
| `runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\c28-il\IMPlatformClient.exe.il:72844` | `callsite` | `IL_0077: callvirt instance class IMPP.Client.control.MessageCenter IMPP.Client.IMPPManager::GetMessageCenter()` | `L72806: .method public hidebysig specialname rtspecialname` |
## Inference
- High confidence: text sending has an internal managed call path that reaches smack chat/connection send APIs.
- Medium confidence: the likely chain is high-level AppContextManager or UI chat code -> MessageScheduling/frmP2PChat -> smack Chat.SendMessage/sendMessage -> XMPPConnection.send.
- Low confidence until live/runtime evidence: an external sidecar can invoke this path without being inside the logged-in process.
## Decision
B-route text send remains plausible only if a bridge can enter the logged-in runtime or an existing IPC/API exposes the same call. Offline IL confirms call shape, but not runtime invocability.
## Next work
1. Extract file-send/upload call graph and separate upload from chat-message finalization.
2. Extract bridge/IPC/plugin candidates to see whether the send call path is reachable without RPA.
3. Keep A-route/RPA as backup-only; do not promote it unless B-route bridge evidence fails.

View File

@@ -0,0 +1,65 @@
# Offline B-Route Reverse Decision
Date: 2026-07-12
Branch: `codex/offline-broute-reverse`
## Current phase / 当前阶段
Offline reverse engineering for the primary B-route is complete for the current evidence set. The goal was to decide whether B-route can continue through an existing IPC/plugin/local API, or whether it must move to an in-process adapter research branch.
## Inputs / 输入证据
| Evidence | Path |
| --- | --- |
| Offline sample index | `docs/source-discovery/2026-07-12-offline-broute-reverse-index.md` |
| Text send static map | `docs/source-discovery/2026-07-12-broute-text-send-static-map.md` |
| File send static map | `docs/source-discovery/2026-07-12-broute-file-send-static-map.md` |
| Bridge/IPC/plugin static map | `docs/source-discovery/2026-07-12-broute-bridge-static-map.md` |
| Machine JSON outputs | `runs/offline-broute-reverse/*.json` |
## Verified facts / 已验证事实
1. The offline sample set is present and hash-matched against the recorded plan list; the analyzer scripts only read metadata/IL and did not copy or modify binaries.
2. Text send has confirmed static call-path evidence:
- `AppContextManager.SendTxtMessageByJid`
- `MessageScheduling.SendChatMessage`
- `Chat.SendMessage` / `Chat.sendMessage`
- `XMPPConnection.send`
3. File send has confirmed two-step evidence:
- upload first through `OffLineFileSend.sendFile`, `FileUploadPara`, and `IFileTransfer.FileUpload` / `IMPP.Service.BLL.FileTransfer.FileUpload`;
- then chat file-message finalization through `trans_UploadCompleted(fileId)`, `MessageScheduling.SendFileMessage`, and `Chat.sendFileMessage`.
4. `FileUploadResult.FileID` / remote-folder metadata is required before the chat file message is meaningful.
5. The static bridge scan found no confirmed existing IPC/plugin/local HTTP route that exposes text-send or file-send.
6. `HttpServerLib` exists, but the discovered `frmLogin.StartWinServer` path depends on logged-in runtime state and no send route was found.
7. `PluginLightAppMulProcessCommunication` declares a `NamedPipeServerStream`, but its start method returns immediately.
8. Returned live probe saw `\\.\pipe\zfpinject-msg-server`, but no matching iSphere static send contract was found.
## Decision / 决策
| Candidate | Decision | Reason |
| --- | --- | --- |
| `existing_ipc_first` | rejected for now | No concrete local HTTP/named-pipe/service send API was confirmed. |
| `plugin_extension_first` | rejected for now | Plugin evidence is UI/app-store/context-menu oriented; no local plugin load/send bridge is confirmed. |
| `in_process_adapter_research` | selected | The send and file-send call paths exist, but they need access to the logged-in `IMPlatformClient.exe` runtime objects. |
| `external_sidecar_preview_only` | allowed | External sidecar can validate contracts/probes, but cannot reuse logged-in static runtime by itself. |
| `fallback_rpa_only` | not selected | A-route/RPA remains backup-only; do not promote it unless B-route is explicitly blocked by a later decision. |
Final offline decision: **B-route stays primary, but the next real branch is `in_process_adapter_research`, not existing IPC/plugin and not production send.**
## Business impact / 业务影响
- Message receiving, file receiving/listing, contact search, and group search stay on the existing data/log-backed roadmap.
- Text send is technically mapped but not business-complete: the missing piece is a bridge into logged-in runtime state and later online ack/sent-record validation.
- File send is harder than text send: upload and chat-message finalization are separate, and upload needs HTTP file-service auth plus server-issued file id.
- RPA remains a practical backup for UI smoke, but it is not the product architecture.
## Next implementation slice / 下一轮
1. Create a dedicated in-process adapter research plan.
2. Keep any sidecar work preview/probe-only until an adapter path is selected.
3. Do not implement real `send_message`, `send_file`, or `upload_file` in this offline branch.
4. Online evidence later must prove sent-record/content-hash/ack before production send can be called complete.
## Stop conditions / 停止条件
Stop and ask for business decision if the next in-process research shows that every viable bridge requires invasive process mutation, unsupported injection, or cannot be validated without a logged-in test environment.

View File

@@ -0,0 +1,45 @@
# Offline B-Route Reverse Index
Date: 2026-07-12
## Scope
This index records the read-only offline samples for B-route reverse engineering. The script reads metadata and SHA256 hashes only; it does not copy or modify the original binaries.
## Summary
- Samples total: 12
- Samples present: 12
- Hash matches: 12
- Originals copied: false
- Originals modified: false
- Machine JSON: `runs/offline-broute-reverse/index.json`
## Sample table
| Sample | Role | Size | SHA256 match | SHA256 |
| --- | --- | ---: | --- | --- |
| `runs/offline-real-client-window/full/zyl/Impp/IMPlatformClient.exe` | main managed client; text send, chat window, plugin/IPC candidates | 8366592 | yes | `E2966E58360DAEEBF178410A961E2DC52748C63E9CCBD94BD6EC8434A1A09CB7` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Service.dll` | file upload service candidates | 21504 | yes | `F859DE2F34E024B5F372D95F22CBF8A018C780F24203E7A76D392E52BD90CADD` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Interface.dll` | service/interface contract candidates | 33792 | yes | `DD093CF61FAE85C2D2581F289B01AA477546712E098FEF81272FDD989E50D12D` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Model.dll` | model/parameter types | 35328 | yes | `7CBABDD23A7234D15E294C549EEEC78EFBAD6E957CD45A6E8A85309349A624A5` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.UI.dll` | UI/plugin helpers and possible send controls | 2401792 | yes | `4C61212519C01D1B731ED4F20DF3411C491616F4ADA98A4529722A6AA3689E6D` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Common.dll` | shared utility and message types | 479744 | yes | `02A47C99C27C9BFE2BADC438389300C92220B07609BD87C48B4B5E1B4EE6AEB4` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Helper.dll` | helper abstractions | 53248 | yes | `30932D082B6678699C832C63F923E01EAD84674FAEDB613D631E034E03742FB9` |
| `runs/offline-real-client-window/full/zyl/Impp/smack.dll` | XMPP chat/send layer | 370688 | yes | `BAD0BB0591765DB153B17CECC1112B0BBDBF3B1D3370DDF973F91974E92CEC66` |
| `runs/offline-real-client-window/full/zyl/Impp/TcpFileTransfer.dll` | native/transfer candidate | 45056 | yes | `6C4874B46D1E7DC04C9B6784180603452400B881E04509D464A0D58814EFEDA7` |
| `runs/offline-real-client-window/full/zyl/Impp/HttpServerLib.dll` | local API/IPC candidate | 26624 | yes | `FFDB633216AE332B8EDFFC83E2AE546B7A80144C659FF4053FDFE4FE382A8D12` |
| `runs/offline-real-client-window/full/zyl/Impp/INetwork.dll` | network abstraction candidate | 87552 | yes | `E40BE4EE2439E4280461F4D2BADB318A5F4572428F069AB6D17B4C4F0E8CFAC9` |
| `runs/offline-real-client-window/full/zyl/Impp/IOClientNetwork.dll` | network implementation candidate | 145920 | yes | `64CA231A614771325741A5F098E52EBF20F7190E339B64962DE2EDEDCFFFF904` |
## Verified facts
- All metadata came from local filesystem reads under the repository workspace.
- The expected primary B-route samples are present if Samples present equals Samples total.
- Hash matching only proves local file identity against this plan's recorded sample list; it does not prove runtime invocability.
## Next reverse targets
1. Text-send static call graph: SendTxtMessageByJid, MessageScheduling.SendChatMessage, Chat.SendMessage, XMPPConnection.send.
2. File-send/upload static call graph: OffLineFileSend.sendFile, FileTransfer.FileUpload, AsynFileUpload, sendFileMessage.
3. Bridge/IPC/plugin map: HttpServerLib, plugin loaders, local HTTP, named pipes, command-line switches, services.

View File

@@ -0,0 +1,49 @@
# Source Discovery Ledger
Date: 2026-07-09
Current loop: Stage C / Loop C1 - log decryptor and PacketReader parser
## Objective
Build the first business read capability path for `isphere_receive_messages` from decrypted `PacketReader.ProcessPacket` XMPP message logs.
## Inputs
- `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md`
- `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
- `docs/source-discovery/capability-source-matrix.md`
- `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`
- `docs/source-discovery/2026-07-10-live-probe-recorder.md`
- `docs/mcp-core-tools-contract.md`
- `docs/mcp-core-business-plan.md`
## Loop protocol
1. Write or update the plan before coding.
2. Implement one loop only.
3. Run loop-specific tests.
4. Run full verification with `go test ./...` and `go build ./cmd/isphere-mcp`.
5. Commit exact paths.
6. Update the next loop based on actual results.
7. Continue automatically while the next step is clear; ask the user only when blocked or when the business direction changes.
## Current loop output
Loop C1 must produce:
1. `internal/isphere/logcodec.DecryptLine` for DES/CBC/PKCS7 Base64 log lines.
2. `internal/isphere/packetlog.ParseMessageLog` for redacted `PacketReader.ProcessPacket` XMPP `<message>` stanzas.
3. Passing unit tests with synthetic/redacted fixtures only.
4. A plan update for Loop C2 before any source abstraction code starts.
## Current plan
See `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`.
## Last completed discovery result
Stage B2 showed that selected `MsgLib.db` files are not direct SQLite. Decrypted `PacketReader.ProcessPacket` XML is now the first implementable source for `isphere_receive_messages`.
C29 corrected the write-side route after static binary evidence review and user decision: `isphere_send_message` should prioritize the running-client sidecar / in-process connector route, with constrained UI/RPA as fallback. The next write-side loop is a non-mutating B-route runtime probe.
C30 adds a portable `ISphereLiveProbeRecorder.exe` package for environments where iSphere can actually log in. The recorder collects read-only runtime evidence and writes JSON output for B-route sidecar feasibility analysis.

View File

@@ -0,0 +1,63 @@
# Capability Source Matrix
Date: 2026-07-12
Stage: Core business capability roadmap
Evidence index: `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md`
Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
Returned live probe: `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`
Send connector preflight: `docs/source-discovery/2026-07-10-send-connector-preflight.md`
Send sandbox gate: `docs/source-discovery/2026-07-10-send-sandbox-gate.md`
Returned send sandbox analysis: `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`
Returned send sandbox analysis v2: `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis-v2.md`
Returned send-sent record diagnostic: `docs/source-discovery/2026-07-10-returned-send-sent-record-diagnostic.md`
File download mapping v2: `docs/source-discovery/2026-07-10-file-download-mapping-v2.md`
Send-file sandbox gate: `docs/source-discovery/2026-07-10-send-file-sandbox-gate.md`
Business goals smoke: `docs/reports/2026-07-10-business-goals-smoke.md`
R14 release candidate: `docs/reports/2026-07-10-r6f-r14-release-candidate.md`
Main status correction: `docs/reports/2026-07-12-main-business-status-correction.md`
A-route UIA send: `docs/source-discovery/2026-07-11-a-route-uia-send.md`
A-route real offline chat window: `docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md`
Offline B-route reverse index: `docs/source-discovery/2026-07-12-offline-broute-reverse-index.md`
Offline B-route text-send map: `docs/source-discovery/2026-07-12-broute-text-send-static-map.md`
Offline B-route file-send map: `docs/source-discovery/2026-07-12-broute-file-send-static-map.md`
Offline B-route bridge map: `docs/source-discovery/2026-07-12-broute-bridge-static-map.md`
Offline B-route decision: `docs/source-discovery/2026-07-12-offline-broute-reverse-decision.md`
## Source priority
1. Running-client sidecar / in-process connector that reuses the logged-in `IMPlatformClient.exe` runtime.
2. Existing bridge / API / local service.
3. Local data, log, cache, or database source.
4. UIA helper source through C# WinHelper.
5. Network/protocol source.
6. Mock source for tests only.
Route rule: UIA helper / A-route / RPA is a backup route only. It can preserve delivery momentum while B-route is blocked or untestable, but B-route sidecar/API evidence remains the preferred path for production send and file capability.
## Matrix
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
| --- | --- | --- | --- | --- | --- |
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | copied `MsgLib.db` message tables through x86 read-only sidecar `list_messages` selected explicitly by `source_preference="msglib_readonly"`; decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md`; `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md`; `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`; `scripts/verify-receive-source-reconciliation.ps1`; `docs/reports/2026-07-10-business-goals-smoke.md` | C41 wires env-configured explicit MsgLib receive with optional sanitized smoke; R12 adds a pure reconciliation helper with strong/medium match summary while keeping default PacketReader/log-backed routing unchanged; R13/R14 business smoke confirms receive-message basic readiness. | completed for basic digital-employee read; future optional route is switching default auto only after real reconciliation evidence |
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/contacts_test.go`; `internal/tools/isphere_contacts_test.go` | C34 documents optional MsgLib contact display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core contact search complete; optional richer fields later |
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/groups_test.go`; `internal/tools/isphere_groups_test.go`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents optional MsgLib group display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core group search complete; optional member/owner hierarchy later |
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | MsgLib `TD_ReceiveFileRecord` safe metadata through explicit copied-DB path; decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck`; `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`; `docs/source-discovery/2026-07-10-file-cache-mapping-diagnostic.md`; `docs/source-discovery/2026-07-10-file-download-mapping-v2.md`; `docs/reports/2026-07-10-business-goals-smoke.md` | C22 verifies safe file-list contract args; R10 proves resolver scoring with fixture accepted matches; R11 adds structured download preview with `blocked`/`planned` status and side-effect flags false; R13/R14 confirms list is ready but real download is still blocked. | blocked for real download pending accepted real cache mapping and copy gate; list mode is complete |
| `isphere_send_message` | B-route selected: `in_process_adapter_research` after offline reverse. Static call path is confirmed through `AppContextManager.SendTxtMessageByJid(...)`, `MessageScheduling.SendChatMessage(...)`, `Chat.SendMessage`, and `XMPPConnection.send`, but no existing IPC/plugin/local HTTP send bridge is confirmed; default MCP tool still exposes preview/dry-run unless an explicit connector mode is configured | A-route fallback is now implemented for UI action: `ISPHERE_SEND_CONNECTOR_MODE=uia_rpa` calls WinHelper `uia_send_message` against a configured HWND, writes `rtbSendMessage`, and clicks `btnSend`; this stays backup-only and network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`; `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis-v2.md`; `docs/source-discovery/2026-07-10-returned-send-sent-record-diagnostic.md`; `docs/source-discovery/2026-07-11-a-route-uia-send.md`; `docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md`; `docs/source-discovery/2026-07-12-broute-text-send-static-map.md`; `docs/source-discovery/2026-07-12-broute-bridge-static-map.md`; `docs/source-discovery/2026-07-12-offline-broute-reverse-decision.md`; `internal/tools/isphere_send_message_test.go`; `cmd/isphere-capability-smoke/main.go`; `docs/reports/2026-07-10-business-goals-smoke.md` | R6f-R6l define connector contract, audit/idempotency replay, production gate, B-route shell, strict-v2 package, and explicit production closure. R10a validates two returned sent-record packages as manual-only evidence. A-route adds local UI action readiness: synthetic window proof plus real offline `frmP2PChat` open/write/click target proof. Offline B-route reverse confirms the send call path but rejects existing IPC/plugin-first for this evidence set; business delivery is still not verified because local runtime is offline and no success/ack or sent-record hash evidence has passed. | Create a dedicated B-route `in_process_adapter_research` plan; keep external sidecar preview/probe-only and A-route UI action backup-only; business send remains blocked until adapter reachability plus online sent-record/content-hash/ack evidence passes |
| `isphere_send_file` | B-route selected after offline reverse: managed file send is upload-first through `OffLineFileSend.sendFile()`, `FileUploadPara`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, then chat file-message finalization through `trans_UploadCompleted(fileId)`, `MessageScheduling.SendFileMessage`, and `Chat.sendFileMessage(...)`; current MCP tool exposes preview/dry-run only | A-route file fallback is not implemented yet; real offline UIA dump did not expose stable `btnSendFile`, so the fallback slice remains toolbar/menu locator discovery; native `TcpFileTransfer.dll` is not the first B-route bridge because managed upload/message flow is now mapped | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `docs/source-discovery/2026-07-10-send-file-sandbox-gate.md`; `docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md`; `docs/source-discovery/2026-07-12-broute-file-send-static-map.md`; `docs/source-discovery/2026-07-12-broute-bridge-static-map.md`; `docs/source-discovery/2026-07-12-offline-broute-reverse-decision.md`; `internal/tools/isphere_files_test.go`; `docs/reports/2026-07-10-business-goals-smoke.md` | R7 registers send-file preview as the tenth MCP tool; R8 adds audit/idempotency duplicate/conflict hardening without raw file paths/content; R9 builds and verifies the online send-file evidence package; R13/R14 confirms preview is ready and production upload remains blocked. Offline B-route reverse confirms upload-first/file-message-second and shows file id/auth/runtime reachability are the blockers. A-route real-window work confirms file-button automation still needs a separate locator node. | blocked until B-route in-process adapter research proves access to logged-in `IFileTransfer`/`Chat` objects and returned file-send package proves upload/sent evidence; A-route locator remains backup-only |
## Stage C entry rule
Open implementation only for the selected row whose evidence maps concrete fields to the MCP output contract. After Stage B2, `isphere_receive_messages` satisfies the entry rule through decrypted `PacketReader.ProcessPacket` XML shape.
## Stage C first slice recommendation
Start Stage C with a narrow log-backed `isphere_receive_messages` source abstraction:
1. Implement DES/CBC/PKCS7 line decryption for `IMPP.Util.Log.LogSecurityPolicy` format.
2. Implement a parser for decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas.
3. Normalize only redacted test fixtures into message structs first.
4. Add the MCP tool registration after the parser/source abstraction passes tests.
## Deferred source work
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. R6j/R6k confirm the send-message strict-v2 production gate is still not passed; R10a confirms the returned sent-record packages are manual-only evidence and do not unlock production send; R10/R11 define file-download resolver/preview behavior while keeping real copy blocked; R12 adds receive-source reconciliation without changing default routing; R13/R14 publish business-goals smoke and the release-candidate status. A-route now proves UI write/click readiness against synthetic and real offline chat windows, but not online delivery. The active B-route follow-up is now the offline decision path in `docs/source-discovery/2026-07-12-offline-broute-reverse-decision.md`: create a dedicated `in_process_adapter_research` plan before production send/file work. A-route RPA remains backup-only under `docs/superpowers/plans/2026-07-11-a-route-rpa-send.md`.

View File

@@ -0,0 +1,474 @@
# MCP Core Source Discovery Loop Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Prepare and execute the next development loop: turn the downloaded N12-pre evidence package into source decisions for the MCP core contact/group/message/file tools.
**Architecture:** The next loop is Stage B source discovery before Stage C implementation. It inventories copied iSphere / IMPlatformClient evidence, records capability-source decisions, then opens the first implementation slice only after the selected source is clear.
**Tech Stack:** Go 1.23.4, PowerShell, 7-Zip, Markdown decision records, existing Go MCP server, existing C# WinHelper.
## Global Constraints
- Repository root: `E:\coding\codex\isphere-ai-bridge`.
- Remote name: `gitea`; active branch: `main`; local starting commit for this plan: `e993ce1`.
- Code search rule from `AGENTS.md`: use `git ls-files`, `ag`, or `grep -R`; do not use `rg`.
- Evidence archive path: `E:\coding\codex\isphere-ai-bridge\runs\offline-evidence-intake\zyl-qqfile-20260709\archives\zyl.rar`.
- Evidence SHA256: `126CC72F21293829FED374321A2AFE6E067959B63A76981BCB6678C64E887173`.
- Raw evidence stays under `runs/`; normal commits contain docs/source/code, not the raw archive.
- Product route: the six MCP core tools in `docs/mcp-core-tools-contract.md`.
- Stage order for this loop: source discovery first, then read-loop implementation, then write capability.
- Commit cadence: one small commit per accepted loop node.
---
## File Structure
- Modify: `E:\coding\codex\isphere-ai-bridge\docs\current-status-card.md` — keep current loop pointer accurate.
- Create/modify: `E:\coding\codex\isphere-ai-bridge\docs\source-discovery\README.md` — live loop ledger and working rules for source discovery.
- Create: `E:\coding\codex\isphere-ai-bridge\docs\source-discovery\2026-07-09-n12-pre-zyl-index.md` — evidence index extracted from archive metadata and later extraction summaries.
- Create: `E:\coding\codex\isphere-ai-bridge\docs\source-discovery\capability-source-matrix.md` — decision table mapping each MCP core tool to primary/fallback source.
- Later implementation files after decisions are accepted:
- `E:\coding\codex\isphere-ai-bridge\internal\isphere\source.go`
- `E:\coding\codex\isphere-ai-bridge\internal\isphere\source_test.go`
- `E:\coding\codex\isphere-ai-bridge\internal\tools\isphere_read_tools.go`
- `E:\coding\codex\isphere-ai-bridge\internal\tools\isphere_read_tools_test.go`
---
### Task 1: Start the source-discovery loop ledger
**Files:**
- Modify: `E:\coding\codex\isphere-ai-bridge\docs\current-status-card.md`
- Create/modify: `E:\coding\codex\isphere-ai-bridge\docs\source-discovery\README.md`
**Interfaces:**
- Consumes: current Git state, `docs/mcp-core-tools-contract.md`, `docs/mcp-core-business-plan.md`.
- Produces: a single visible loop entry point for the next worker.
- [ ] **Step 1: Refresh repo state**
Run:
```powershell
git fetch gitea
git status --short --branch
git log --oneline -3
```
Expected:
```text
## main...gitea/main [ahead 2]
e993ce1 docs: trim safety constraints from mcp roadmap
cfc7787 docs: realign iSphere bridge around core MCP capabilities
```
- [ ] **Step 2: Update `docs/current-status-card.md`**
Set the loop base line:
```markdown
Loop base: `e993ce1 docs: trim safety constraints from mcp roadmap`
Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-report-hardening'`
```
Add this current-loop block:
```markdown
## Current loop
Current loop: `Stage B1 - source discovery from N12-pre zyl package`.
Plan file: `docs/superpowers/plans/2026-07-09-mcp-core-source-discovery-loop.md`.
Loop output:
1. Evidence index for downloaded `zyl.rar` package.
2. Capability-source matrix for the four read tools:
- `isphere_search_contacts`
- `isphere_search_groups`
- `isphere_receive_messages`
- `isphere_receive_files`
3. Selected first Stage C implementation slice.
```
- [ ] **Step 3: Create the loop ledger**
Create `docs/source-discovery/README.md` with this content:
```markdown
# Source Discovery Ledger
Date: 2026-07-09
Current loop: Stage B1 - N12-pre zyl evidence source discovery
## Objective
Find the best real capability source for the MCP core read tools before implementing Stage C.
## Inputs
- `runs/offline-evidence-intake/zyl-qqfile-20260709/archives/zyl.rar`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/archive-list.txt`
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/sha256.txt`
- `docs/mcp-core-tools-contract.md`
- `docs/mcp-core-business-plan.md`
## Loop cadence
1. Inventory evidence.
2. Identify source candidates.
3. Map candidates to MCP tools.
4. Select primary/fallback source per tool.
5. Open one Stage C implementation slice.
6. Verify with `go test ./...`, `go build ./cmd/isphere-mcp`, and MCP verification script.
7. Commit exact paths.
## Current target order
1. `isphere_receive_messages`
2. `isphere_search_contacts`
3. `isphere_search_groups`
4. `isphere_receive_files`
Reason: message source discovery is the current blocker; contacts/groups can be derived or selected after the message source shape is known.
## Current plan
See `docs/superpowers/plans/2026-07-09-mcp-core-source-discovery-loop.md`.
```
- [ ] **Step 4: Verify docs diff**
Run:
```powershell
git diff --check
git diff -- docs/current-status-card.md docs/source-discovery/README.md
```
Expected: `git diff --check` exits 0 and the diff only touches the two listed docs.
- [ ] **Step 5: Commit Task 1**
Run:
```powershell
git add -- docs/current-status-card.md docs/source-discovery/README.md
git diff --cached --check
git commit -m "docs: start mcp source discovery loop"
```
Expected: commit succeeds.
---
### Task 2: Build the N12-pre zyl evidence index
**Files:**
- Create: `E:\coding\codex\isphere-ai-bridge\docs\source-discovery\2026-07-09-n12-pre-zyl-index.md`
- Read local input: `E:\coding\codex\isphere-ai-bridge\runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\archive-list.txt`
**Interfaces:**
- Consumes: archive metadata, `archive-list.txt`, `sha256.txt`.
- Produces: a reviewable evidence index without committing raw evidence files.
- [ ] **Step 1: Confirm archive metadata and candidate paths**
Run:
```powershell
Get-Content -LiteralPath runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\sha256.txt
Select-String -LiteralPath runs\offline-evidence-intake\zyl-qqfile-20260709\metadata\archive-list.txt -Pattern 'MsgLib\.db|Smark\.SendReceive|SaveToDB|PacketReader\.ProcessPacket|LoginInfo|System\.Data\.SQLite|importal\\[0-9a-f]{32}' -CaseSensitive:$false | Select-Object -First 200
```
Expected: sha256 matches `126CC72F21293829FED374321A2AFE6E067959B63A76981BCB6678C64E887173`; candidate file paths are visible.
- [ ] **Step 2: Create the evidence index file**
Create `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md` with this concrete starting content, then extend it only with exact paths from `archive-list.txt`:
```markdown
# N12-pre zyl Evidence Index
Date: 2026-07-09
Evidence id: `zyl-qqfile-20260709`
Archive: `runs/offline-evidence-intake/zyl-qqfile-20260709/archives/zyl.rar`
SHA256: `126CC72F21293829FED374321A2AFE6E067959B63A76981BCB6678C64E887173`
Archive type: RAR5
Archive listing summary: 3931 files, 439 folders
## Candidate source families
| Family | Evidence path examples | Why it matters | Initial action |
| --- | --- | --- | --- |
| SQLite runtime | `zyl\iSphere\System.Data.SQLite.dll`; `zyl\Impp\System.Data.SQLite.dll` | Confirms bundled SQLite usage around message libraries | record versions and inspect dependent DB files |
| iSphere message DB | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005981\MsgLib.db`; `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\duyanming0731\MsgLib.db` | Strong candidate for `isphere_receive_messages`, contacts, groups, and attachment references | extract selected DB copies and inspect schema |
| IMPP message DB | `zyl\Impp\Account\90005981\MsgLib.db`; `zyl\Impp\Account\27091282\MsgLib.db` | Older or parallel candidate source for message history | compare schema with iSphere DB |
| Local communication logs | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Smark.SendReceive\2026-07-07_1.log`; `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\SaveToDB\2026-07-07_1.log`; `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\PacketReader.ProcessPacket\2026-07-07_1.log` | Candidate execution trace for message receive/save behavior | inspect log structure and timestamp alignment |
| Login/info logs | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\LoginInfo\2026-07-07_1.log` | Candidate account/session context for source mapping | inspect field names and account identifiers |
| Received/document cache | `zyl\importal\256914c029af44bd96665abd69ddd500\*.xlsx`; `zyl\importal\4a33b65a33c14149b440724bfc3d912e\g.docx`; `zyl\importal\4fa0846abdbb4331a956cb056be9566a\*.pdf` | Candidate file receive/download cache for `isphere_receive_files` | map hashed directories to DB/log references |
## First extraction target list
| Priority | Archive path | Destination under runs | Reason |
| --- | --- | --- | --- |
| 1 | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005981\MsgLib.db` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-isphere-90005981/` | latest visible iSphere message DB candidate |
| 2 | `zyl\Impp\Account\90005981\MsgLib.db` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/msgdb-impp-90005981/` | compare older or parallel IMPP DB candidate |
| 3 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\Smark.SendReceive\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-smark-90005981-20260707/` | message send/receive trace candidate |
| 4 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\SaveToDB\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-savetodb-90005981-20260707/` | DB write trace candidate |
| 5 | `zyl\importal\localcache\8c5e81a7f6dd1169.10083\27000000\Log\90005981\LoginInfo\2026-07-07_1.log` | `runs/offline-evidence-intake/zyl-qqfile-20260709/extracted/log-logininfo-90005981-20260707/` | account/session context candidate |
## Decision after index
Proceed to `docs/source-discovery/capability-source-matrix.md` after the first extraction target list has confirmed message DB and log candidates.
```
- [ ] **Step 3: Verify index contains concrete candidates**
Run:
```powershell
Select-String -LiteralPath docs\source-discovery\2026-07-09-n12-pre-zyl-index.md -Pattern '待补|占位'
```
Expected: no output.
- [ ] **Step 4: Commit Task 2**
Run:
```powershell
git add -- docs/source-discovery/2026-07-09-n12-pre-zyl-index.md
git diff --cached --check
git commit -m "docs: index n12-pre zyl evidence sources"
```
Expected: commit succeeds.
---
### Task 3: Create the capability-source matrix
**Files:**
- Create: `E:\coding\codex\isphere-ai-bridge\docs\source-discovery\capability-source-matrix.md`
**Interfaces:**
- Consumes: `docs/mcp-core-tools-contract.md`, `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md`.
- Produces: source decisions that Stage C implementation can consume.
- [ ] **Step 1: Create the matrix**
Create `docs/source-discovery/capability-source-matrix.md` with this content:
```markdown
# Capability Source Matrix
Date: 2026-07-09
Stage: B source discovery
## Source priority
1. Existing bridge / API / local service.
2. Local data, log, cache, or database source.
3. UIA helper source through C# WinHelper.
4. Network/protocol source.
5. Mock source for tests only.
## Matrix
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
| --- | --- | --- | --- | --- | --- |
| `isphere_receive_messages` | `zyl\iSphere\8c5e81a7f6dd1169.10083\27000000\Account\90005981\MsgLib.db` | `zyl\Impp\Account\90005981\MsgLib.db` plus `Smark.SendReceive` logs | `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md#first-extraction-target-list` | selected after schema confirms message table | first |
| `isphere_search_contacts` | message DB contact/conversation tables after schema inspection | UIA helper source through current WinHelper if DB lacks display names | `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md#candidate-source-families` | candidate | second |
| `isphere_search_groups` | message DB group/conversation tables after schema inspection | UIA helper source through current WinHelper if DB lacks group display names | `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md#candidate-source-families` | candidate | third |
| `isphere_receive_files` | importal hashed document cache mapped through message DB attachment references | `Smark.SendReceive` and `SaveToDB` logs for file-reference traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-index.md#candidate-source-families` | candidate | fourth |
## Stage C entry rule
Open implementation only after the selected row links to a concrete file or directory in the evidence index and the schema/log inspection notes state which fields map to the MCP output contract.
```
- [ ] **Step 2: Verify the matrix contains concrete candidates**
Run:
```powershell
Select-String -LiteralPath docs\source-discovery\capability-source-matrix.md -Pattern '待补|占位'
```
Expected: no output.
- [ ] **Step 3: Commit Task 3**
Run:
```powershell
git add -- docs/source-discovery/capability-source-matrix.md
git diff --cached --check
git commit -m "docs: select mcp capability sources"
```
Expected: commit succeeds.
---
### Task 4: Open the first Stage C implementation slice
**Files:**
- Create: `E:\coding\codex\isphere-ai-bridge\internal\isphere\source.go`
- Create: `E:\coding\codex\isphere-ai-bridge\internal\isphere\source_test.go`
- Modify: the current tool registration file found with `git ls-files internal | Select-String -Pattern 'tool|mcp|registry|server'`.
- Create/modify: `E:\coding\codex\isphere-ai-bridge\internal\tools\isphere_read_tools_test.go`
**Interfaces:**
- Consumes: selected source row for `isphere_receive_messages`.
- Produces: first business MCP tool skeleton with normalized JSON and audit object.
- [ ] **Step 1: Locate current tool registration**
Run:
```powershell
git ls-files internal | Select-String -Pattern 'tool|mcp|registry|server'
```
Expected: the file that registers `win_helper_version`, `win_helper_self_check`, `win_helper_scan_windows`, and `win_helper_dump_uia` is visible.
- [ ] **Step 2: Write failing test for source abstraction**
Create `internal/isphere/source_test.go` with this test:
```go
package isphere
import (
"context"
"testing"
)
func TestMockMessageSourceReturnsNormalizedMessages(t *testing.T) {
src := MockMessageSource{
Messages: []Message{
{
ID: "msg-1",
ConversationID: "conv-1",
SenderName: "测试用户",
Text: "hello",
},
},
}
got, err := src.ReceiveMessages(context.Background(), ReceiveMessagesQuery{ConversationID: "conv-1", Limit: 10})
if err != nil {
t.Fatalf("ReceiveMessages returned error: %v", err)
}
if len(got.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(got.Messages))
}
if got.Messages[0].ID != "msg-1" || got.Messages[0].ConversationID != "conv-1" {
t.Fatalf("unexpected message: %+v", got.Messages[0])
}
}
```
Run:
```powershell
go test ./internal/isphere -run TestMockMessageSourceReturnsNormalizedMessages -v
```
Expected: FAIL because package/types do not exist yet.
- [ ] **Step 3: Implement minimal source abstraction**
Create `internal/isphere/source.go`:
```go
package isphere
import "context"
type Message struct {
ID string `json:"message_id"`
ConversationID string `json:"conversation_id"`
SenderName string `json:"sender_name"`
Text string `json:"text"`
}
type ReceiveMessagesQuery struct {
ConversationID string
Limit int
}
type ReceiveMessagesResult struct {
Messages []Message `json:"messages"`
}
type MessageSource interface {
ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error)
}
type MockMessageSource struct {
Messages []Message
}
func (s MockMessageSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error) {
if query.Limit <= 0 || query.Limit > len(s.Messages) {
query.Limit = len(s.Messages)
}
out := make([]Message, 0, query.Limit)
for _, msg := range s.Messages {
if query.ConversationID != "" && msg.ConversationID != query.ConversationID {
continue
}
out = append(out, msg)
if len(out) == query.Limit {
break
}
}
return ReceiveMessagesResult{Messages: out}, nil
}
```
- [ ] **Step 4: Verify source abstraction**
Run:
```powershell
go test ./internal/isphere -run TestMockMessageSourceReturnsNormalizedMessages -v
```
Expected: PASS.
- [ ] **Step 5: Commit Task 4 source abstraction**
Run:
```powershell
git add -- internal/isphere/source.go internal/isphere/source_test.go
git diff --cached --check
go test ./...
go build ./cmd/isphere-mcp
git commit -m "feat: add isphere message source abstraction"
```
Expected: all commands exit 0 and commit succeeds.
---
## Self-Review
- Spec coverage: this plan covers the next loop requested by the user: source discovery, evidence organization, capability-source selection, and first Stage C entry.
- Placeholder scan: the plan contains concrete paths and explicit verification commands for future generated docs.
- Type consistency: `MessageSource`, `ReceiveMessagesQuery`, `ReceiveMessagesResult`, and `MockMessageSource` are defined before use.
## Execution Handoff
Plan saved for the next loop. Recommended execution mode: one node at a time.
1. Start with Task 1 and Task 2 only.
2. Stop after the evidence index is concrete.
3. Ask the business manager to accept the selected first source.
4. Open Task 3 and Task 4 only after acceptance.

View File

@@ -0,0 +1,907 @@
# N14 UIA Selector Report Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an offline N14 selector report generator that reads a UIA dump file, runs the N13 selector catalog, and writes safe JSON/Markdown reports without adding live helper or MCP actions.
**Architecture:** Extend `internal/uiaselector` with a focused report model, catalog report generator, strict-mode classification, and JSON/Markdown renderers. Add a small `cmd/uia-selector-report` CLI that only accepts dump-file paths and writes reports; PowerShell verifier wraps tests and CLI execution.
**Tech Stack:** Go 1.23.x, standard library `encoding/json`, `flag`, `time`, `os`, existing `internal/uiaselector` package, PowerShell verification wrapper.
---
## Scope and boundary
This plan implements only offline report generation.
Do not modify these files:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
```
Do not add helper ops, MCP tools, live window handles, process discovery, UI automation API calls, clicks, keyboard input, clipboard actions, conversation actions, message sending, file transfer, or visible text export.
Every selector remains constrained to `read_only_locate`. Reports must not output full UIA `name` or `value` fields. Reports may output only derived text-safety fields already used by N13:
```text
has_name
name_length
```
## File structure
Create these files:
```text
internal/uiaselector/report_test.go
internal/uiaselector/report.go
cmd/uia-selector-report/main_test.go
cmd/uia-selector-report/main.go
scripts/verify-n14-uia-selector-report.ps1
```
Responsibilities:
- `internal/uiaselector/report.go`: report data structures, catalog report generation, strict exit classification, JSON rendering/writing, Markdown rendering/writing.
- `internal/uiaselector/report_test.go`: unit tests for catalog reporting, strict classification, and no-visible-text report rendering.
- `cmd/uia-selector-report/main.go`: CLI argument parsing and file output orchestration.
- `cmd/uia-selector-report/main_test.go`: CLI tests using temporary output files and the N12R redacted fixture.
- `scripts/verify-n14-uia-selector-report.ps1`: Go test + CLI smoke verification + output safety scans.
## Task 1: Add report model, generator, and strict-mode classification
**Files:**
- Create: `internal/uiaselector/report_test.go`
- Create: `internal/uiaselector/report.go`
- [ ] **Step 1: Write failing report generator tests**
Create `internal/uiaselector/report_test.go`:
```go
package uiaselector
import (
"testing"
"time"
)
func fixedReportTime() time.Time {
return time.Date(2026, 7, 9, 0, 0, 0, 0, time.UTC)
}
func loadN14Fixture(t *testing.T) *Node {
t.Helper()
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
return root
}
func TestRunCatalogReportMatchesAllN13Selectors(t *testing.T) {
report := RunCatalogReport(
"testdata/n12r-2026-07-09-uia-redacted.json",
loadN14Fixture(t),
DefaultCatalog(),
fixedReportTime(),
)
if !report.OK {
t.Fatalf("report OK=false: %#v", report.Error)
}
if report.SourceDump != "testdata/n12r-2026-07-09-uia-redacted.json" {
t.Fatalf("source_dump = %q", report.SourceDump)
}
if report.GeneratedAtUTC != "2026-07-09T00:00:00Z" {
t.Fatalf("generated_at_utc = %q", report.GeneratedAtUTC)
}
if report.CatalogSize != 10 {
t.Fatalf("catalog_size = %d, want 10", report.CatalogSize)
}
if report.MatchedCount != 10 {
t.Fatalf("matched_count = %d, want 10", report.MatchedCount)
}
if report.UnmatchedCount != 0 {
t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount)
}
if report.AmbiguousCount != 0 {
t.Fatalf("ambiguous_count = %d, want 0", report.AmbiguousCount)
}
if got := StrictExitCode(report); got != StrictExitOK {
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitOK)
}
}
func TestStrictModeClassifiesFailures(t *testing.T) {
catalog := []Selector{
{
ID: "missing_panel",
Description: "missing structural test selector",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "does_not_exist", ControlType: "Pane", FrameworkID: "WinForm"},
StabilityScore: 80,
Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/missing"},
},
}
report := RunCatalogReport("fixture.json", loadN14Fixture(t), catalog, fixedReportTime())
if !report.OK {
t.Fatalf("report OK=false: %#v", report.Error)
}
if report.UnmatchedCount != 1 {
t.Fatalf("unmatched_count = %d, want 1", report.UnmatchedCount)
}
if got := StrictExitCode(report); got != StrictExitUnmatched {
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitUnmatched)
}
}
func TestReportRejectsNonReadOnlyCatalogEntries(t *testing.T) {
catalog := []Selector{
{
ID: "bad_write_selector",
Description: "invalid write selector",
AllowedUse: "write",
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
StabilityScore: 1,
Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root"},
},
}
report := RunCatalogReport("fixture.json", loadN14Fixture(t), catalog, fixedReportTime())
if report.OK {
t.Fatalf("report OK=true for invalid catalog")
}
if report.Error == nil || report.Error.Code != "INVALID_CATALOG" {
t.Fatalf("report error = %#v, want INVALID_CATALOG", report.Error)
}
if got := StrictExitCode(report); got != StrictExitInvalidCatalog {
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitInvalidCatalog)
}
}
```
- [ ] **Step 2: Run report generator tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run "TestRunCatalogReport|TestStrictMode|TestReportRejects" -count=1
```
Expected: FAIL with compile errors for undefined symbols such as `RunCatalogReport`, `StrictExitCode`, and `StrictExitOK`.
If `go` is unavailable, stop and install or expose Go 1.23.x before continuing.
- [ ] **Step 3: Implement report model, generator, and strict exit classification**
Create `internal/uiaselector/report.go`:
```go
package uiaselector
import "time"
const (
StrictExitOK = 0
StrictExitReportGenerationFailure = 1
StrictExitUnmatched = 2
StrictExitAmbiguous = 3
StrictExitInvalidCatalog = 4
)
type Report struct {
OK bool `json:"ok"`
SourceDump string `json:"source_dump"`
CatalogSize int `json:"catalog_size"`
MatchedCount int `json:"matched_count"`
UnmatchedCount int `json:"unmatched_count"`
AmbiguousCount int `json:"ambiguous_count"`
GeneratedAtUTC string `json:"generated_at_utc"`
Selectors []SelectorReport `json:"selectors"`
Warnings []string `json:"warnings"`
Error *ReportError `json:"error,omitempty"`
}
type ReportError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type SelectorReport struct {
SelectorID string `json:"selector_id"`
Description string `json:"description"`
AllowedUse string `json:"allowed_use"`
StabilityScore int `json:"stability_score"`
Evidence Evidence `json:"evidence"`
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
Matches []NodeMatch `json:"matches"`
Warnings []string `json:"warnings"`
}
func RunCatalogReport(sourceDump string, root *Node, catalog []Selector, generatedAt time.Time) Report {
report := Report{
OK: true,
SourceDump: sourceDump,
CatalogSize: len(catalog),
GeneratedAtUTC: generatedAt.UTC().Format(time.RFC3339),
Warnings: []string{},
}
if root == nil {
report.OK = false
report.Error = &ReportError{Code: "NO_ROOT", Message: "UIA dump root is nil"}
return report
}
for _, selector := range catalog {
if selector.AllowedUse != AllowedUseReadOnlyLocate {
report.OK = false
report.Error = &ReportError{Code: "INVALID_CATALOG", Message: "selector is not read-only: " + selector.ID}
return report
}
match := MatchSelector(root, catalog, selector.ID)
selectorReport := SelectorReport{
SelectorID: selector.ID,
Description: selector.Description,
AllowedUse: selector.AllowedUse,
StabilityScore: selector.StabilityScore,
Evidence: selector.Evidence,
Matched: match.Matched,
MatchCount: match.MatchCount,
Matches: match.Matches,
Warnings: append([]string{}, match.Warnings...),
}
report.Selectors = append(report.Selectors, selectorReport)
if match.Matched {
report.MatchedCount++
} else {
report.UnmatchedCount++
}
if match.MatchCount > 1 {
report.AmbiguousCount++
}
}
return report
}
func StrictExitCode(report Report) int {
if !report.OK {
if report.Error != nil && report.Error.Code == "INVALID_CATALOG" {
return StrictExitInvalidCatalog
}
return StrictExitReportGenerationFailure
}
if report.UnmatchedCount > 0 {
return StrictExitUnmatched
}
if report.AmbiguousCount > 0 {
return StrictExitAmbiguous
}
return StrictExitOK
}
```
- [ ] **Step 4: Run report generator tests and verify they pass**
Run:
```powershell
gofmt -w internal\uiaselector\report.go internal\uiaselector\report_test.go
go test ./internal/uiaselector -run "TestRunCatalogReport|TestStrictMode|TestReportRejects" -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit report generator**
Run:
```powershell
git add internal\uiaselector\report.go internal\uiaselector\report_test.go
git commit -m "feat: add N14 selector report model"
```
## Task 2: Add JSON and Markdown report rendering
**Files:**
- Modify: `internal/uiaselector/report_test.go`
- Modify: `internal/uiaselector/report.go`
- [ ] **Step 1: Write failing rendering tests**
Append these tests to `internal/uiaselector/report_test.go`:
```go
func TestReportDoesNotExposeVisibleText(t *testing.T) {
report := RunCatalogReport(
"testdata/n12r-2026-07-09-uia-redacted.json",
loadN14Fixture(t),
DefaultCatalog(),
fixedReportTime(),
)
body, err := RenderReportJSON(report)
if err != nil {
t.Fatalf("RenderReportJSON: %v", err)
}
jsonText := string(body)
for _, forbidden := range []string{`"name"`, `"value"`, "国网甘肃省电力公司"} {
if strings.Contains(jsonText, forbidden) {
t.Fatalf("JSON report exposed forbidden text %q in %s", forbidden, jsonText)
}
}
for _, required := range []string{`"has_name"`, `"name_length"`, `"selector_id"`, `"automation_id"`} {
if !strings.Contains(jsonText, required) {
t.Fatalf("JSON report missing required structural field %q in %s", required, jsonText)
}
}
}
func TestMarkdownReportDoesNotExposeVisibleText(t *testing.T) {
report := RunCatalogReport(
"testdata/n12r-2026-07-09-uia-redacted.json",
loadN14Fixture(t),
DefaultCatalog(),
fixedReportTime(),
)
markdown := RenderReportMarkdown(report)
for _, forbidden := range []string{"国网甘肃省电力公司", "| name |", "| value |"} {
if strings.Contains(markdown, forbidden) {
t.Fatalf("Markdown report exposed forbidden text %q in %s", forbidden, markdown)
}
}
for _, required := range []string{"# N14 UIA Selector Report", "## Summary", "automation_id", "control_type", "class_name", "has_name", "name_length"} {
if !strings.Contains(markdown, required) {
t.Fatalf("Markdown report missing %q in %s", required, markdown)
}
}
}
```
Add `strings` to the import block so the file begins with:
```go
import (
"strings"
"testing"
"time"
)
```
- [ ] **Step 2: Run rendering tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run "TestReportDoesNotExposeVisibleText|TestMarkdownReportDoesNotExposeVisibleText" -count=1
```
Expected: FAIL with compile errors for undefined `RenderReportJSON` and `RenderReportMarkdown`.
- [ ] **Step 3: Implement JSON and Markdown rendering/writing**
Append this code to `internal/uiaselector/report.go`:
```go
func RenderReportJSON(report Report) ([]byte, error) {
return json.MarshalIndent(report, "", " ")
}
func WriteReportJSON(report Report, path string) error {
body, err := RenderReportJSON(report)
if err != nil {
return err
}
return writeFileWithParents(path, body)
}
func RenderReportMarkdown(report Report) string {
var b strings.Builder
b.WriteString("# N14 UIA Selector Report\n\n")
b.WriteString("- Source dump: `" + escapeMarkdownInline(report.SourceDump) + "`\n")
b.WriteString(fmt.Sprintf("- Catalog size: %d\n", report.CatalogSize))
b.WriteString(fmt.Sprintf("- Matched: %d\n", report.MatchedCount))
b.WriteString(fmt.Sprintf("- Unmatched: %d\n", report.UnmatchedCount))
b.WriteString(fmt.Sprintf("- Ambiguous: %d\n", report.AmbiguousCount))
b.WriteString("\n## Summary\n\n")
b.WriteString("| selector_id | status | matches | stability | evidence_path |\n")
b.WriteString("| --- | --- | ---: | ---: | --- |\n")
for _, selector := range report.Selectors {
status := "unmatched"
if selector.MatchCount > 1 {
status = "ambiguous"
} else if selector.Matched {
status = "matched"
}
b.WriteString(fmt.Sprintf(
"| %s | %s | %d | %d | %s |\n",
escapeMarkdownCell(selector.SelectorID),
status,
selector.MatchCount,
selector.StabilityScore,
escapeMarkdownCell(selector.Evidence.UIAPath),
))
}
b.WriteString("\n## Selector Details\n")
for _, selector := range report.Selectors {
b.WriteString("\n### " + escapeMarkdownInline(selector.SelectorID) + "\n\n")
b.WriteString("- Description: " + escapeMarkdownInline(selector.Description) + "\n")
b.WriteString("- Allowed use: " + escapeMarkdownInline(selector.AllowedUse) + "\n")
b.WriteString(fmt.Sprintf("- Stability score: %d\n", selector.StabilityScore))
b.WriteString(fmt.Sprintf("- Match count: %d\n", selector.MatchCount))
b.WriteString("\nMatches:\n\n")
b.WriteString("| path | control_type | automation_id | class_name | framework_id | has_name | name_length |\n")
b.WriteString("| --- | --- | --- | --- | --- | --- | ---: |\n")
for _, match := range selector.Matches {
b.WriteString(fmt.Sprintf(
"| %s | %s | %s | %s | %s | %t | %d |\n",
escapeMarkdownCell(match.Path),
escapeMarkdownCell(match.ControlType),
escapeMarkdownCell(match.AutomationID),
escapeMarkdownCell(match.ClassName),
escapeMarkdownCell(match.FrameworkID),
match.HasName,
match.NameLength,
))
}
}
return b.String()
}
func WriteReportMarkdown(report Report, path string) error {
return writeFileWithParents(path, []byte(RenderReportMarkdown(report)))
}
func writeFileWithParents(path string, body []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return os.WriteFile(path, body, 0o644)
}
func escapeMarkdownInline(value string) string {
return strings.NewReplacer("`", "'", "\r", " ", "\n", " ").Replace(value)
}
func escapeMarkdownCell(value string) string {
return strings.NewReplacer("|", "\\|", "\r", " ", "\n", " ").Replace(value)
}
```
Update the import block in `internal/uiaselector/report.go` to:
```go
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
```
- [ ] **Step 4: Run rendering tests and verify they pass**
Run:
```powershell
gofmt -w internal\uiaselector\report.go internal\uiaselector\report_test.go
go test ./internal/uiaselector -run "TestReportDoesNotExposeVisibleText|TestMarkdownReportDoesNotExposeVisibleText" -count=1
```
Expected: PASS.
- [ ] **Step 5: Run package tests and commit rendering**
Run:
```powershell
go test ./internal/uiaselector -count=1
git add internal\uiaselector\report.go internal\uiaselector\report_test.go
git commit -m "feat: render N14 selector reports"
```
Expected: package tests PASS before commit.
## Task 3: Add offline selector report CLI
**Files:**
- Create: `cmd/uia-selector-report/main_test.go`
- Create: `cmd/uia-selector-report/main.go`
- [ ] **Step 1: Write failing CLI tests**
Create `cmd/uia-selector-report/main_test.go`:
```go
package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunWritesJSONAndMarkdownReports(t *testing.T) {
tmp := t.TempDir()
jsonPath := filepath.Join(tmp, "report.json")
mdPath := filepath.Join(tmp, "report.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run([]string{
"-dump", "../../internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json",
"-out-json", jsonPath,
"-out-md", mdPath,
"-strict",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("Run exit code = %d, stderr=%s stdout=%s", code, stderr.String(), stdout.String())
}
jsonBody, err := os.ReadFile(jsonPath)
if err != nil {
t.Fatalf("read JSON report: %v", err)
}
var parsed struct {
OK bool `json:"ok"`
CatalogSize int `json:"catalog_size"`
MatchedCount int `json:"matched_count"`
UnmatchedCount int `json:"unmatched_count"`
AmbiguousCount int `json:"ambiguous_count"`
}
if err := json.Unmarshal(jsonBody, &parsed); err != nil {
t.Fatalf("JSON report did not parse: %v", err)
}
if !parsed.OK || parsed.CatalogSize != 10 || parsed.MatchedCount != 10 || parsed.UnmatchedCount != 0 || parsed.AmbiguousCount != 0 {
t.Fatalf("unexpected JSON report summary: %#v", parsed)
}
markdown, err := os.ReadFile(mdPath)
if err != nil {
t.Fatalf("read Markdown report: %v", err)
}
if !strings.Contains(string(markdown), "## Summary") {
t.Fatalf("Markdown report missing Summary: %s", string(markdown))
}
}
func TestRunRequiresDumpPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run([]string{"-strict"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("Run exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "missing -dump") {
t.Fatalf("stderr missing dump message: %s", stderr.String())
}
}
```
- [ ] **Step 2: Run CLI tests and verify they fail**
Run:
```powershell
go test ./cmd/uia-selector-report -count=1
```
Expected: FAIL because package or `Run` does not exist.
- [ ] **Step 3: Implement CLI**
Create `cmd/uia-selector-report/main.go`:
```go
package main
import (
"flag"
"fmt"
"io"
"os"
"time"
"isphere-ai-bridge/internal/uiaselector"
)
func main() {
os.Exit(Run(os.Args[1:], os.Stdout, os.Stderr))
}
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
flags := flag.NewFlagSet("uia-selector-report", flag.ContinueOnError)
flags.SetOutput(stderr)
dumpPath := flags.String("dump", "", "UIA dump JSON path")
outJSON := flags.String("out-json", "", "JSON report output path")
outMD := flags.String("out-md", "", "Markdown report output path")
strict := flags.Bool("strict", false, "return non-zero for unmatched, ambiguous, or invalid selector reports")
if err := flags.Parse(args); err != nil {
return uiaselector.StrictExitReportGenerationFailure
}
if *dumpPath == "" {
fmt.Fprintln(stderr, "missing -dump")
return uiaselector.StrictExitReportGenerationFailure
}
root, err := uiaselector.LoadDumpFile(*dumpPath)
if err != nil {
fmt.Fprintf(stderr, "load dump: %v\n", err)
return uiaselector.StrictExitReportGenerationFailure
}
report := uiaselector.RunCatalogReport(*dumpPath, root, uiaselector.DefaultCatalog(), time.Now().UTC())
if *outJSON != "" {
if err := uiaselector.WriteReportJSON(report, *outJSON); err != nil {
fmt.Fprintf(stderr, "write JSON report: %v\n", err)
return uiaselector.StrictExitReportGenerationFailure
}
}
if *outMD != "" {
if err := uiaselector.WriteReportMarkdown(report, *outMD); err != nil {
fmt.Fprintf(stderr, "write Markdown report: %v\n", err)
return uiaselector.StrictExitReportGenerationFailure
}
}
code := uiaselector.StrictExitCode(report)
if *strict && code != uiaselector.StrictExitOK {
fmt.Fprintf(stderr, "strict report check failed with code %d\n", code)
return code
}
if !report.OK {
fmt.Fprintf(stderr, "report generation failed: %#v\n", report.Error)
return uiaselector.StrictExitReportGenerationFailure
}
fmt.Fprintf(stdout, "N14 selector report generated: catalog=%d matched=%d unmatched=%d ambiguous=%d\n", report.CatalogSize, report.MatchedCount, report.UnmatchedCount, report.AmbiguousCount)
return uiaselector.StrictExitOK
}
```
- [ ] **Step 4: Run CLI tests and verify they pass**
Run:
```powershell
gofmt -w cmd\uia-selector-report\main.go cmd\uia-selector-report\main_test.go
go test ./cmd/uia-selector-report -count=1
```
Expected: PASS.
- [ ] **Step 5: Run relevant tests and commit CLI**
Run:
```powershell
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
git add cmd\uia-selector-report\main.go cmd\uia-selector-report\main_test.go
git commit -m "feat: add N14 selector report CLI"
```
Expected: both packages PASS before commit.
## Task 4: Add N14 verification script
**Files:**
- Create: `scripts/verify-n14-uia-selector-report.ps1`
- [ ] **Step 1: Create verification script**
Create `scripts/verify-n14-uia-selector-report.ps1`:
```powershell
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $repo
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "go was not found. Run this verifier in a Go 1.23.x environment."
}
Write-Host "## go test ./internal/uiaselector ./cmd/uia-selector-report"
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
if ($LASTEXITCODE -ne 0) {
throw "go tests failed with exit code $LASTEXITCODE"
}
$outDir = Join-Path $repo "runs\n14-selector-report"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$jsonPath = Join-Path $outDir "n12r-selector-report.json"
$mdPath = Join-Path $outDir "n12r-selector-report.md"
Write-Host "## go run ./cmd/uia-selector-report"
go run ./cmd/uia-selector-report `
-dump internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json `
-out-json $jsonPath `
-out-md $mdPath `
-strict
if ($LASTEXITCODE -ne 0) {
throw "uia-selector-report failed with exit code $LASTEXITCODE"
}
if (-not (Test-Path -LiteralPath $jsonPath)) {
throw "JSON report was not created: $jsonPath"
}
if (-not (Test-Path -LiteralPath $mdPath)) {
throw "Markdown report was not created: $mdPath"
}
$report = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $report.ok) {
throw "report ok=false"
}
if ($report.catalog_size -ne 10) {
throw "catalog_size=$($report.catalog_size), want 10"
}
if ($report.matched_count -ne 10) {
throw "matched_count=$($report.matched_count), want 10"
}
if ($report.unmatched_count -ne 0) {
throw "unmatched_count=$($report.unmatched_count), want 0"
}
if ($report.ambiguous_count -ne 0) {
throw "ambiguous_count=$($report.ambiguous_count), want 0"
}
Write-Host "## output safety scan"
$jsonRaw = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8
$mdRaw = Get-Content -LiteralPath $mdPath -Raw -Encoding UTF8
$forbiddenOutput = @(
'"name"',
'"value"',
'国网甘肃省电力公司',
'send_after_approval',
'send_file_after_approval',
'receive_file_after_approval',
'open_conversation',
'InvokePattern',
'ValuePattern.SetValue'
)
foreach ($term in $forbiddenOutput) {
if ($jsonRaw.Contains($term)) {
throw "JSON report contains forbidden term: $term"
}
if ($mdRaw.Contains($term)) {
throw "Markdown report contains forbidden term: $term"
}
}
Write-Host "N14 UIA selector report verification passed."
```
- [ ] **Step 2: Run verifier and verify it passes**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
```
Expected:
```text
## go test ./internal/uiaselector ./cmd/uia-selector-report
ok isphere-ai-bridge/internal/uiaselector
ok isphere-ai-bridge/cmd/uia-selector-report
## go run ./cmd/uia-selector-report
N14 selector report generated: catalog=10 matched=10 unmatched=0 ambiguous=0
## output safety scan
N14 UIA selector report verification passed.
```
- [ ] **Step 3: Commit verifier**
Run:
```powershell
git add scripts\verify-n14-uia-selector-report.ps1
git commit -m "test: add N14 selector report verifier"
```
## Task 5: Final verification and boundary check
**Files:**
- No new files.
- Verify: all N14 implementation files and unchanged helper/MCP action surfaces.
- [ ] **Step 1: Run package tests**
Run:
```powershell
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
```
Expected: both packages PASS.
- [ ] **Step 2: Run repository tests**
Run:
```powershell
go test ./...
```
Expected: all packages PASS.
If dependency download fails with a transient proxy error, retry once with:
```powershell
$env:GOPROXY = "https://goproxy.cn,direct"
go test ./...
```
- [ ] **Step 3: Run N14 verifier**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
```
Expected: verifier PASS and writes ignored reports under `runs/n14-selector-report/`.
- [ ] **Step 4: Check no helper/MCP action surface changed**
Run:
```powershell
git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go
```
Expected: no output.
If any of those paths appear, stop and review because N14 is offline-only.
- [ ] **Step 5: Check N14 changed files**
Run:
```powershell
git diff --name-only main..HEAD
```
Expected N14 implementation files are limited to:
```text
docs/superpowers/specs/2026-07-09-n14-uia-selector-report-design.md
internal/uiaselector/report.go
internal/uiaselector/report_test.go
cmd/uia-selector-report/main.go
cmd/uia-selector-report/main_test.go
scripts/verify-n14-uia-selector-report.ps1
```
Additional plan file is acceptable if this implementation plan is committed on the branch:
```text
docs/superpowers/plans/2026-07-09-n14-uia-selector-report-implementation.md
```
- [ ] **Step 6: Commit final checkpoint if needed**
If final verification changes documentation or scripts, run:
```powershell
git add <changed files>
git commit -m "docs: record N14 selector report verification"
```
If no files changed, do not create an empty commit.
## Self-review checklist
- Spec coverage: this plan covers report model, full catalog matching, JSON report, Markdown report, strict mode, CLI, verifier, and final boundary checks.
- Boundary: no helper op, no MCP tool, no live hwnd, no UI action, no visible text export.
- TDD: every Go implementation task begins with failing tests and then minimal implementation.
- Commit cadence: report model, renderers, CLI, verifier are separate commits.
- Output safety: tests and verifier scan for exact forbidden visible-text fields and known root visible text.
- Environment: Go 1.23.x is required; use `GOPROXY=https://goproxy.cn,direct` only for transient dependency download failures.

View File

@@ -0,0 +1,466 @@
# N15 Selector Report Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden the N14 offline selector report tests and verifier so ambiguous strict-mode behavior and report-output safety regressions are caught before merge.
**Architecture:** Keep N15 as a test/verifier-only change. Add an in-memory ambiguous-match test to `internal/uiaselector/report_test.go`, then strengthen `scripts/verify-n14-uia-selector-report.ps1` with stale-output cleanup, file/non-empty checks, and JSON match-field allowlist validation.
**Tech Stack:** Go 1.23.x tests, existing `internal/uiaselector` package, Windows PowerShell 5.1-compatible verifier script, standard `go test` and repository Git workflow.
---
## Scope and boundary
N15 changes only test and verifier coverage.
Modify only:
```text
internal/uiaselector/report_test.go
scripts/verify-n14-uia-selector-report.ps1
```
Do not modify:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
cmd/uia-selector-report/main.go
internal/uiaselector/report.go
internal/uiaselector/catalog.go
internal/uiaselector/matcher.go
```
Do not add selectors, helper ops, MCP tools, CLI arguments, live hwnd/process handling, UI automation calls, clicks, keyboard input, clipboard operations, conversation actions, message sending, file transfer, or visible text export.
## File structure
```text
internal/uiaselector/report_test.go
Add one explicit ambiguous strict-mode regression test using an in-memory UIA tree.
scripts/verify-n14-uia-selector-report.ps1
Clean stale outputs before CLI execution, require fresh non-empty report files, and validate JSON match fields with an allowlist.
```
## Task 1: Cover ambiguous strict-mode classification
**Files:**
- Modify: `internal/uiaselector/report_test.go`
- [ ] **Step 1: Add the ambiguous strict-mode regression test**
Append this test to `internal/uiaselector/report_test.go`:
```go
func TestStrictModeClassifiesAmbiguousMatches(t *testing.T) {
root := &Node{
ControlType: "Window",
AutomationID: "frmMain",
FrameworkID: "WinForm",
Children: []Node{
{ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"},
{ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"},
},
}
catalog := []Selector{
{
ID: "duplicate_panel",
Description: "duplicate structural selector",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "duplicate", ControlType: "Pane", FrameworkID: "WinForm"},
StabilityScore: 80,
Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/duplicate"},
},
}
report := RunCatalogReport("synthetic-ambiguous.json", root, catalog, fixedReportTime())
if !report.OK {
t.Fatalf("report OK=false: %#v", report.Error)
}
if report.CatalogSize != 1 {
t.Fatalf("catalog_size = %d, want 1", report.CatalogSize)
}
if report.MatchedCount != 1 {
t.Fatalf("matched_count = %d, want 1", report.MatchedCount)
}
if report.UnmatchedCount != 0 {
t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount)
}
if report.AmbiguousCount != 1 {
t.Fatalf("ambiguous_count = %d, want 1", report.AmbiguousCount)
}
if len(report.Selectors) != 1 {
t.Fatalf("len(selectors) = %d, want 1", len(report.Selectors))
}
if got := report.Selectors[0].MatchCount; got != 2 {
t.Fatalf("selector match_count = %d, want 2", got)
}
if got := StrictExitCode(report); got != StrictExitAmbiguous {
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitAmbiguous)
}
}
```
- [ ] **Step 2: Prove the test catches a broken ambiguous branch**
Temporarily change `internal/uiaselector/report.go` in `StrictExitCode` from:
```go
if report.AmbiguousCount > 0 {
return StrictExitAmbiguous
}
```
to:
```go
if report.AmbiguousCount > 0 {
return StrictExitOK
}
```
Run:
```powershell
gofmt -w internal\uiaselector\report_test.go
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
```
Expected: FAIL with:
```text
StrictExitCode = 0, want 3
```
Restore `internal/uiaselector/report.go` immediately after this command so production code again returns `StrictExitAmbiguous` for ambiguous reports. Do not commit the temporary mutation.
- [ ] **Step 3: Run the ambiguous test with production code restored**
Run:
```powershell
gofmt -w internal\uiaselector\report_test.go internal\uiaselector\report.go
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
```
Expected: PASS.
- [ ] **Step 4: Run package tests**
Run:
```powershell
go test ./internal/uiaselector -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit ambiguous strict-mode test**
Run:
```powershell
git add internal\uiaselector\report_test.go
git diff --cached --name-only
```
Expected staged file list:
```text
internal/uiaselector/report_test.go
```
Then commit:
```powershell
git commit -m "test: cover N15 ambiguous strict exit"
```
## Task 2: Harden N14 selector report verifier
**Files:**
- Modify: `scripts/verify-n14-uia-selector-report.ps1`
- [ ] **Step 1: Run a failing pre-change verifier hardening check**
Run this PowerShell check before editing the verifier:
```powershell
$scriptPath = "scripts\verify-n14-uia-selector-report.ps1"
$scriptText = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8
$requiredSnippets = @(
'Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue',
'$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop',
'if ($jsonItem.PSIsContainer)',
'if ($jsonItem.Length -le 0)',
'$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop',
'$allowedMatchFields = @(',
'$match.PSObject.Properties',
'JSON match leaks visible text field'
)
foreach ($snippet in $requiredSnippets) {
if (-not $scriptText.Contains($snippet)) {
throw "missing verifier hardening snippet: $snippet"
}
}
```
Expected: FAIL, with the first missing snippet indicating stale-output cleanup is not implemented yet.
- [ ] **Step 2: Insert stale-output cleanup before CLI execution**
In `scripts/verify-n14-uia-selector-report.ps1`, after:
```powershell
$jsonPath = Join-Path $outDir "n12r-selector-report.json"
$mdPath = Join-Path $outDir "n12r-selector-report.md"
```
insert:
```powershell
Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue
```
- [ ] **Step 3: Replace output file existence checks with file and non-empty checks**
Replace this block:
```powershell
if (-not (Test-Path -LiteralPath $jsonPath)) {
throw "JSON report was not created: $jsonPath"
}
if (-not (Test-Path -LiteralPath $mdPath)) {
throw "Markdown report was not created: $mdPath"
}
```
with:
```powershell
$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop
if ($jsonItem.PSIsContainer) {
throw "JSON report path is not a file: $jsonPath"
}
if ($jsonItem.Length -le 0) {
throw "JSON report is empty: $jsonPath"
}
$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop
if ($mdItem.PSIsContainer) {
throw "Markdown report path is not a file: $mdPath"
}
if ($mdItem.Length -le 0) {
throw "Markdown report is empty: $mdPath"
}
```
- [ ] **Step 4: Add JSON match-field allowlist validation after report summary checks**
After this block:
```powershell
if ($report.ambiguous_count -ne 0) {
throw "ambiguous_count=$($report.ambiguous_count), want 0"
}
```
insert:
```powershell
Write-Host "## JSON match field allowlist"
$allowedMatchFields = @(
"path",
"control_type",
"automation_id",
"class_name",
"framework_id",
"has_name",
"name_length"
)
$allowedMatchFieldLookup = @{}
foreach ($field in $allowedMatchFields) {
$allowedMatchFieldLookup[$field] = $true
}
foreach ($selector in @($report.selectors)) {
foreach ($match in @($selector.matches)) {
if ($null -eq $match) {
continue
}
foreach ($property in @($match.PSObject.Properties)) {
$fieldName = $property.Name
if ($fieldName -eq "name" -or $fieldName -eq "value") {
throw "JSON match leaks visible text field: $fieldName"
}
if (-not $allowedMatchFieldLookup.ContainsKey($fieldName)) {
throw "JSON match contains disallowed field: $fieldName"
}
}
}
}
```
- [ ] **Step 5: Run the hardening check again**
Run:
```powershell
$scriptPath = "scripts\verify-n14-uia-selector-report.ps1"
$scriptText = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8
$requiredSnippets = @(
'Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue',
'$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop',
'if ($jsonItem.PSIsContainer)',
'if ($jsonItem.Length -le 0)',
'$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop',
'$allowedMatchFields = @(',
'$match.PSObject.Properties',
'JSON match leaks visible text field'
)
foreach ($snippet in $requiredSnippets) {
if (-not $scriptText.Contains($snippet)) {
throw "missing verifier hardening snippet: $snippet"
}
}
Write-Host "VERIFIER_HARDENING_SNIPPETS_OK"
```
Expected:
```text
VERIFIER_HARDENING_SNIPPETS_OK
```
- [ ] **Step 6: Run the hardened verifier**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
```
Expected output includes:
```text
## go test ./internal/uiaselector ./cmd/uia-selector-report
## go run ./cmd/uia-selector-report
N14 selector report generated: catalog=10 matched=10 unmatched=0 ambiguous=0
## JSON match field allowlist
## output safety scan
N14 UIA selector report verification passed.
```
- [ ] **Step 7: Commit verifier hardening**
Run:
```powershell
git add scripts\verify-n14-uia-selector-report.ps1
git diff --cached --name-only
```
Expected staged file list:
```text
scripts/verify-n14-uia-selector-report.ps1
```
Then commit:
```powershell
git commit -m "test: harden N15 selector report verifier"
```
## Task 3: Final verification and boundary check
**Files:**
- No new files.
- Verify: `internal/uiaselector/report_test.go`, `scripts/verify-n14-uia-selector-report.ps1`, and unchanged helper/MCP action surface.
- [ ] **Step 1: Run targeted tests**
Run:
```powershell
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
```
Expected: both commands PASS.
- [ ] **Step 2: Run repository tests**
Run:
```powershell
go test ./...
```
Expected: all packages PASS.
If dependency download fails with a transient proxy error, retry once with:
```powershell
$env:GOPROXY = "https://goproxy.cn,direct"
go test ./...
```
- [ ] **Step 3: Run the hardened verifier**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
```
Expected: verifier PASS and output includes `## JSON match field allowlist`.
- [ ] **Step 4: Check helper/MCP/live action boundary**
Run:
```powershell
git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go cmd/uia-selector-report/main.go internal/uiaselector/report.go internal/uiaselector/catalog.go internal/uiaselector/matcher.go
```
Expected: no output.
If any file appears, stop and inspect because N15 is test/verifier-only.
- [ ] **Step 5: Check changed files**
Run:
```powershell
git diff --name-only main..HEAD
```
Expected changed files are limited to:
```text
docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md
docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md
internal/uiaselector/report_test.go
scripts/verify-n14-uia-selector-report.ps1
```
- [ ] **Step 6: Final status**
Run:
```powershell
git status --short --branch
git log --oneline --max-count=6
```
Expected: working tree clean on `codex/n15-report-hardening`. Do not push unless the user explicitly requests pushing this branch or merging to main.
## Self-review checklist
- Spec coverage: ambiguous strict-mode test, stale-output cleanup, file/non-empty checks, JSON match-field allowlist, retained forbidden string scan, and helper/MCP boundary check are all covered.
- Scope: only `internal/uiaselector/report_test.go` and `scripts/verify-n14-uia-selector-report.ps1` are implementation targets; design and plan docs are documentation targets.
- Boundary: no helper op, no MCP tool, no live hwnd/process behavior, no UI action, no report format change, no CLI argument change.
- Verification: final commands include targeted tests, all repository tests, hardened verifier, boundary diff check, and changed-file check.
- Commit cadence: test and verifier hardening are separate commits.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
# C30 Send Sidecar Runtime Probe Plan
Date: 2026-07-10
Route decision: B primary, A fallback
Parent: `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`
## Objective
Prove whether the running logged-in iSphere / `IMPlatformClient.exe` client on this machine can support the B-route sidecar connector.
This node is read-only. It does not send messages, upload files, click UI, type text, inject hooks, or attach runtime instrumentation.
## Scope
Use `ISphereWinHelper` op:
```text
probe_client_runtime
```
For a machine where iSphere can actually log in, prefer the portable package:
```text
runs/live-probe-recorder-package.zip
```
It collects the runtime probe plus process details, network metadata, filesystem candidates, registry/service/shortcut inventory, and UIA target-window dumps in one run.
Collect:
- target process PID;
- process name;
- main window title/handle;
- executable path;
- process bitness;
- helper host bitness;
- target-process command-line metadata with redaction;
- key module presence:
- `IMPlatformClient.exe`
- `smack.dll`
- `IMPP.Interface.dll`
- `IMPP.ServiceBase.dll`
- `IMPP.Service.dll`
- `TcpFileTransfer.dll`
- `HYHC.IMPP.DAL.dll`
- install/cache/config/log/database candidate metadata;
- key file versions and hashes;
- target-process TCP connections and related named pipes;
- registry uninstall entries, services, shortcuts;
- UIA target-window dumps for A-route fallback.
## Acceptance gates
### B1 runtime discovery
Pass when:
- at least one target process is returned;
- target score favors `IMPlatformClient` / iSphere;
- executable path is present;
- bitness is known or module probing still succeeds.
Fail when:
- no target process is running;
- process cannot be inspected enough to determine path/bitness;
- discovered process does not match iSphere/IMPlatformClient.
### B2 module availability
Pass when:
- `IMPlatformClient.exe` is confirmed;
- `smack.dll` is present in loaded modules or install path evidence;
- `IMPP.Interface.dll`, `IMPP.ServiceBase.dll`, and `IMPP.Service.dll` are present or explainably loadable.
Fail when:
- `smack.dll` is absent and no install-path fallback is available;
- service/interface DLLs are absent;
- helper bitness prevents module inspection and no x86/x64 helper variant is available.
## Commands
Build and verify helper:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
```
Run live runtime probe after the user has logged in to iSphere:
```powershell
$request = @{
protocol = 'isphere.helper.v1'
request_id = 'c30-live-runtime-probe'
op = 'probe_client_runtime'
timeout_ms = 5000
args = @{
process_names = @('IMPlatformClient', 'IMPP.ISphere', 'iSphere', 'importal')
include_modules = $true
max_modules = 160
}
} | ConvertTo-Json -Depth 8 -Compress
$request | .\runs\win-helper\ISphereWinHelper.exe | Tee-Object -FilePath .\runs\real-loggedin-lab\sidecar-probe\c30-runtime-probe.json
```
If the target is 32-bit and module probing fails from the default helper, rebuild x86 helper:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-win-helper.ps1 -Platform x86 -OutputDir runs\win-helper-x86
```
Then rerun the same request against:
```powershell
.\runs\win-helper-x86\ISphereWinHelper.exe
```
## Output artifacts
If a logged-in client is available, write:
```text
runs/real-loggedin-lab/sidecar-probe/c30-runtime-probe.json
```
Then update:
```text
docs/source-discovery/capability-source-matrix.md
```
## Next node decision
If B1/B2 pass:
- move to C31 non-mutating manual-send observation plan;
- observe whether manual send hits `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and `XMPPConnection.send`.
If B1/B2 fail:
- document exact failure;
- decide whether to try x86/x64 helper variant;
- if still blocked, activate A-route constrained UI/RPA fallback.

View File

@@ -0,0 +1,683 @@
# Core Business Capabilities Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver the four business-facing iSphere MCP goals: search contacts, search groups, receive/send messages, and receive/send files.
**Architecture:** The product API is the Go MCP server. Read-side local evidence sources are PacketReader encrypted logs and copied `MsgLib.db` through the bounded x86 `MsgLibReadSidecar`. Write-side capabilities must use a validated connector before production execution: bridge/API/local service first, then UIA helper, then network/protocol only after discovery.
**Tech Stack:** Go 1.23.4, `github.com/modelcontextprotocol/go-sdk`, PowerShell verification scripts, x86 .NET Framework sidecar for `MsgLib.db`, C# WinHelper for UIA fallback.
## Global Constraints
- Repository root: `E:\coding\codex\isphere-ai-bridge`.
- Remote name: `gitea`; active branch: `main`.
- Code search rule from `AGENTS.md`: use `git ls-files`, `ag`, or `grep -R`; do not use `rg`.
- Do not commit raw DB/log files, decrypted message content, local file paths from copied evidence, or operator credentials.
- Standard deterministic verification must keep MsgLib env cleared unless a script is explicitly named as optional MsgLib verification.
- `auto` for `isphere_receive_messages` remains PacketReader/log-backed until reconciliation is complete; copied DB receive requires `source_preference="msglib_readonly"`.
- Do not implement send-message, file-download, or send-file production behavior until the selected connector has a focused evidence note and passing preview/dry-run tests.
- Each implementation node must end with `git diff --check`, `go test ./...`, `go build ./cmd/isphere-mcp`, and the relevant PowerShell smoke.
---
## Business Goal Status
| Business goal | Current capability | Remaining blockers | Next node |
| --- | --- | --- | --- |
| Search contacts | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across log/JID candidates and optional copied-DB MsgLib display enrichment. | Optional richer fields such as department/title remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Search groups | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across groupchat/conference candidates and optional copied-DB MsgLib display enrichment. | Optional member count, owner refs, and nested/group hierarchy remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 |
| Send messages | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata; R6e adds sandbox-only connector shell metadata and idempotency/audit state. | Full production still needs after-recorder output/manual timestamps or a controlled online sandbox connector pass. | next R6f fake/sandbox connector contract |
| Receive files | List mode exists from message-derived file metadata; R3/R3b confirm DB file metadata exists but current cache/archive evidence does not map to local files. | R4 download is blocked until better cache evidence or a UI/client download connector is available. | blocked; continue R5 |
| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind returned send-message sandbox evidence plus upload/file-transfer dry-run. | wait for R6d, then file-send upload preflight |
## Node Sequence
### R0: Business roadmap rebaseline
**Purpose:** Replace the previous technology-led loop with this four-goal business roadmap.
**Files:**
- Create: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`
- Modify: `docs/current-status-card.md`
- Modify: `docs/source-discovery/capability-source-matrix.md`
**Acceptance gate:**
```powershell
git diff --check
go test ./...
```
**Done when:**
- Current status points to this roadmap.
- The next implementation node is R1.
- No code behavior changes are included in R0.
---
### R1: Receive-message source reconciliation precheck
**Purpose:** Decide how PacketReader messages and MsgLib DB messages relate, without changing default routing.
**Files:**
- Create: `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`
- Modify: `scripts/verify-msglib-mcp-enrichment.ps1` only if an additional sanitized count/ref is required.
- Modify: this roadmap and `docs/current-status-card.md`.
**Evidence to compare:**
- PacketReader: message id, sender id, receiver/group id, message type, body-present boolean, subject, timestamp.
- MsgLib: `message_id`, `conversation_id`, `conversation_type`, sender/receiver ids, body-present boolean, subject, timestamp, `raw_ref`.
- File metadata: filename/size/download-ref-present boolean only.
**Steps:**
- [x] **Step 1: Write the precheck note skeleton**
Create `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md` with these headings:
```markdown
# Receive Message Reconciliation Precheck
Date: 2026-07-10
## Goal
Compare PacketReader and MsgLib receive-message sources using sanitized identifiers, counts, source refs, and booleans only.
## Sources
## Candidate match keys
## Known mismatches
## Safe evidence allowed in logs
## Decision for default routing
## Next implementation node
```
- [x] **Step 2: Fill candidate match keys**
Record these exact candidate keys:
```text
strong: message_id exact match when both sources expose the same id
medium: same conversation id, same sender id, timestamp within a small window
medium: same group id, same sender id, same subject/body-present flag
weak: filename plus nearby timestamp for file-transfer messages
not allowed in committed evidence: message body values, local file paths, raw rows
```
- [x] **Step 3: Run verification**
```powershell
git diff --check
go test ./...
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
$env:ISPHERE_MSGLIB_SQLITE_DLL = "<path-to-copied-System.Data.SQLite.dll>"
$env:ISPHERE_MSGLIB_DB = "<path-to-copied-MsgLib.db>"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-mcp-enrichment.ps1
```
**Acceptance gate:**
- The note states whether C43 should implement a reconciliation helper, pagination, or file mapping first.
- Default MCP receive still uses PacketReader for empty/`auto`.
**Stop condition:**
- If safe sanitized keys are not enough to compare sources, stop and request either more evidence or permission for a focused local-only diagnostic script.
**R1 Result:**
- Created `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`.
- Candidate match keys are defined as strong/medium/weak using message id, conversation/sender ids, timestamp windows, body-present booleans, and safe file metadata.
- Default routing remains unchanged: empty/`auto`/`local_readonly` use PacketReader; `msglib_readonly` is explicit only.
- Decision: next business node is R2 contact/group search quality hardening. A receive-specific reconciliation helper can be added later before any `auto` merge.
---
### R2: Contact and group search quality hardening
**Purpose:** Make search results business-usable before relying on them for send targets.
**Files:**
- Modify: `internal/isphere/contacts.go`, `internal/isphere/groups.go`
- Modify tests: `internal/isphere/contacts_test.go`, `internal/isphere/groups_test.go`
- Modify optional display mapping if needed: `internal/tools/display_entities.go`
- Modify docs: `docs/go-mcp-runbook.md`, this roadmap, `docs/current-status-card.md`
**Steps:**
- [x] **Step 1: Add failing contact ranking test**
Target behavior:
```text
exact display-name/id match ranks before substring match;
duplicates from log and MsgLib collapse into one result;
source remains visible through raw_ref/source.
```
Run:
```powershell
go test ./internal/isphere -run TestSearchContactsRanksExactAndDeduplicates -v
```
Expected before implementation: FAIL because ranking/dedup behavior is not implemented.
- [x] **Step 2: Implement contact ranking/dedup**
Implement minimal deterministic ranking in `internal/isphere/contacts.go`.
- [x] **Step 3: Add failing group ranking test**
Run:
```powershell
go test ./internal/isphere -run TestSearchGroupsRanksExactAndDeduplicates -v
```
Expected before implementation: FAIL because ranking/dedup behavior is not implemented.
- [x] **Step 4: Implement group ranking/dedup**
Implement minimal deterministic ranking in `internal/isphere/groups.go`.
- [ ] **Step 5: Verify**
```powershell
git diff --check
go test ./...
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
```
**Acceptance gate:**
- Search contacts and search groups produce deterministic ranked results.
- No send behavior is introduced.
**R2 Result:**
- Added failing-then-passing tests for contact and group exact-match ranking and case-insensitive de-duplication in `internal/isphere`.
- Added failing-then-passing MCP tool tests proving optional MsgLib display entities and log-derived fallback entries merge into one de-duplicated result while preserving `source` and `raw_ref`.
- Implemented deterministic ranking: exact ID/display/account match first, prefix match second, substring match after that, then stable case-insensitive ID ordering.
- No send-message, send-file, file-download, hook, DB-write, or production side-effect behavior was introduced.
- Decision: next business node is R3 receive-file download mapping precheck.
---
### R3: Receive-file download mapping precheck
**Purpose:** Determine how file metadata maps to an existing local cache file or a client-triggered download.
**Files:**
- Create: `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`
- Modify: `docs/source-discovery/capability-source-matrix.md`
- Modify: this roadmap and `docs/current-status-card.md`
**Evidence to inspect:**
- PacketReader file-transfer stanza fields.
- MsgLib `TD_ReceiveFileRecord` safe metadata: message id ref, filename, size, source id, send time.
- Existing ignored `importal`/cache directory names only as sanitized path-shape evidence.
**Acceptance gate:**
- The note chooses one next node:
1. cache-file resolver,
2. UI/client download connector,
3. additional evidence request.
- No file content or local file path values are committed.
**R3 Result:**
- Created `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`.
- PacketReader remains sufficient for file metadata list mode, but has 0 cache path and 0 download keyword hits in the C12 safe count evidence.
- MsgLib `TD_ReceiveFileRecord` provides safe attachment metadata (`ReceiveMsgGuid`, filename, size, source id), but current sidecar intentionally returns empty `download_ref` and `file_paths_returned=false`.
- Ignored cache evidence shows 11 importal hash-shape files under 32-character hash directories, but no validated message/file-record-to-cache-file mapping.
- Decision: do not start R4 yet; insert R3b file cache mapping diagnostic as the next node.
---
### R3b: File cache mapping diagnostic
**Purpose:** Prove or reject a safe mapping from MsgLib received-file metadata to local cache files before implementing download mode.
**Files:**
- Create or modify: `scripts/verify-file-cache-mapping.ps1` or an equivalent local-only diagnostic.
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/source-discovery/capability-source-matrix.md`.
**Diagnostic policy:**
- Read copied DB/cache metadata only; do not read attachment contents.
- Do not print local paths, raw rows, real message bodies, or unredacted file names.
- Output counts, extension groups, hash-directory shape, match strategy counts, and safety booleans only.
**Acceptance gate:**
- Diagnostic output includes `raw_paths_printed=false`, `file_contents_read=false`, and `file_paths_returned=false`.
- Diagnostic chooses one route: R4 cache-file resolver, UI/client download connector, or additional evidence request.
**Stop condition:**
- If no unique/repeatable cache mapping exists, do not implement R4 download mode.
**R3b Result:**
- Added `scripts/verify-file-cache-mapping.ps1` and `scripts/test-verify-file-cache-mapping.ps1`.
- Created `docs/source-discovery/2026-07-10-file-cache-mapping-diagnostic.md`.
- Real sanitized diagnostic checked 166 `TD_ReceiveFileRecord` rows and 11 ignored cache/archive candidates.
- Match counts were all zero: filename-only, filename+size, unique filename+size, and source-id-to-hash-dir.
- Safety booleans stayed false for raw paths, file contents, file paths returned, raw rows, and message body printing.
- Decision: R4 remains blocked; continue the broader roadmap at R5 send-message connector discovery.
---
### R4: Receive-file download implementation
**Purpose:** Implement `isphere_receive_files` download mode only after R3 identifies a validated mapping.
**Files:**
- Modify: `internal/isphere/file_source.go`, `internal/isphere/files.go`
- Modify tests: `internal/isphere/file_source_test.go`, `internal/isphere/files_test.go`
- Modify tool: `internal/tools/isphere_files.go`, `internal/tools/isphere_files_test.go`
- Modify verification: `scripts/verify-go-mcp.ps1`
**Execution policy:**
- Preview remains the default for download.
- Production download requires `output_dir`.
- Saved path must be under the requested output directory.
- Response includes `sha256` and `saved_path`; audit includes `file_sha256`.
**Acceptance gate:**
```powershell
git diff --check
go test ./...
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
```
**Stop condition:**
- If no validated mapping exists, do not implement download mode.
---
### R5: Send-message connector discovery
**Purpose:** Select the first safe write connector for sending text messages.
**Files:**
- Create: `docs/source-discovery/2026-07-10-send-message-connector-selection.md`
- Modify: `docs/source-discovery/capability-source-matrix.md`
- Modify: this roadmap and `docs/current-status-card.md`
**Connector priority:**
1. Existing bridge/API/local service.
2. UIA helper action chain in a logged-in internal sandbox.
3. Network/protocol connector after protocol discovery.
**Required evidence per connector candidate:**
- How to resolve a target contact/group id.
- How to preview the target and content.
- How to execute exactly once.
- How to confirm success.
- What failure states look like.
**Acceptance gate:**
- One connector is selected for R6, or send remains blocked with an exact evidence request.
**Stop condition:**
- Do not implement a production sender without connector evidence.
**R5 Result:**
- Created `docs/source-discovery/2026-07-10-send-message-connector-selection.md`.
- No existing bridge/API/local service was found in committed code or docs.
- Current WinHelper supports only `version`, `self_check`, `scan_windows`, and `dump_uia`; no click/type/draft/send helper op exists.
- Current UIA selector catalog is read-only locate only and does not prove conversation-open, editor, send-button, or post-send confirmation selectors.
- Current network/log evidence supports receive/transport analysis, but not an authorized sent-message request/ack contract.
- Decision: no connector selected; R6/R7/R8 write-side implementation is blocked pending an exact bridge/API, UIA action-chain, or network/protocol evidence package.
---
### R5b: Returned live-probe evidence intake
**Purpose:** Consume the returned `ISphereLiveProbeRecorder` package and decide whether the send connector has enough runtime evidence to move from generic blocked status to a non-mutating preflight node.
**Files:**
- Create: `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`
- Modify: `docs/source-discovery/capability-source-matrix.md`
- Modify: this roadmap and `docs/current-status-card.md`
**Evidence checked:**
- `probe_client_runtime.json`
- `filesystem_inventory.json`
- `network_inventory.json`
- `scan_windows.json`
- `uia/*.json`
- `services_registry_shortcuts.json`
**Acceptance gate:**
- The note states B1/B2/A-route status separately.
- Production `isphere_send_message` and `isphere_send_file` remain blocked unless runtime send entrypoint reachability and success/ack evidence are proven.
- The next node is either B-route entrypoint preflight, A-route fallback preflight, or another evidence request.
**R5b Result:**
- Created `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`.
- B1 runtime discovery passes: one x86 `IMPlatformClient.exe` `4.1.2.6842` process, install path family, .NET/WPF/CEF evidence, and live TCP endpoint were observed.
- B2 file presence partially passes: live install contains `IMPlatformClient.exe`, `smack.dll`, `IMPP.Interface.dll`, `IMPP.Service*.dll`, `IMPP.UI.dll`, `TcpFileTransfer.dll`, `INetwork.dll`, and `IOClientNetwork.dll`.
- B2 runtime entrypoint reachability remains unproven: the process module list did not show `IMPP.*`, `smack.dll`, or file-transfer assemblies as currently loaded.
- A-route fallback evidence improved: UIA finds main search edit `skinAlphaTxt`, chat send document `rtbSendMessage`, send button `btnSend`, and `发送文件` menu controls.
- Production send remains blocked: the chat captures explicitly show an offline-send blocker and no send success/ack or idempotency evidence.
- Decision: next node is R6a/C31 non-mutating send connector preflight.
---
### R6a: Non-mutating send connector preflight
**Purpose:** Prove whether B route can continue through a read-only entrypoint probe and whether A route can reliably classify search/edit/send/file controls and offline-state blockers.
**Files:**
- Modify: `native/ISphereWinHelper/*` if adding helper op `probe_send_entrypoints` or a UIA send-control classifier.
- Modify: `internal/helperclient/*` and tests only if Go needs to call the new helper op.
- Modify: `docs/go-csharp-helper-boundary.md`, this roadmap, `docs/current-status-card.md`, and `docs/source-discovery/capability-source-matrix.md`.
**Execution policy:**
- No message send.
- No file upload.
- No UI click/type.
- No process injection/hook.
- No network capture.
- Reflection/metadata inspection is allowed against copied or installed binaries.
- UIA inspection is allowed only for locating controls and reading offline/disabled state.
**Acceptance gate:**
- `probe_send_entrypoints` or equivalent metadata evidence reports availability for:
- `AppContextManager.SendTxtMessageByJid`;
- `MessageScheduling.SendChatMessage`;
- `com.vision.smack.Chat.SendMessage`;
- file-upload/file-message candidates such as `FileTransfer.FileUpload` and `OffLineFileSend`.
- UIA preflight reports whether `skinAlphaTxt`, `rtbSendMessage`, `btnSend`, and `发送文件` controls are present and whether an offline-send blocker is visible.
- The result chooses one next node: B-route dry-run, A-route draft-only preflight, or another live evidence request.
**Stop condition:**
- If entrypoints cannot be confirmed or the live client remains offline, do not start R6 production send.
**R6a Result:**
- Created `docs/source-discovery/2026-07-10-send-connector-preflight.md`.
- Added C# WinHelper `0.4.0` read-only op `probe_send_entrypoints`.
- Added C# WinHelper `0.4.0` read-only op `probe_send_uia_controls`.
- Updated `ISphereLiveProbeRecorder` to include `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`.
- Manual copied-install metadata probe reports `required_available_count=5`, `all_required_available=true`, and `b_route_entrypoint_preflight="pass"`.
- Synthetic UIA classifier smoke reports `A_ROUTE_OFFLINE_BLOCKED`, matching the R5b live evidence pattern where offline-send blockers must stop production send.
- Decision: next node is R6b B-route preview/dry-run contract. Production `isphere_send_message` and `isphere_send_file` remain blocked.
---
### R6b: B-route preview/dry-run contract
**Purpose:** Add only the non-sending contract layer needed before any real send attempt: target ref input, content/hash/idempotency validation, connector metadata, and local audit for preview/dry-run.
**Files:**
- Create or modify: `internal/tools/isphere_send_message.go`
- Create or modify tests: `internal/tools/isphere_send_message_test.go`
- Modify: `internal/mcpserver/server.go`, `internal/mcpserver/server_test.go`
- Modify: `scripts/verify-go-mcp.ps1`
- Modify docs: `docs/go-mcp-runbook.md`, this roadmap, `docs/current-status-card.md`
**Execution policy:**
- `execution_mode="preview"` or equivalent dry-run returns `send_status="planned"` and never invokes helper send, UI click/type, network capture, upload, or hook.
- `execution_mode="production"` remains rejected until a later sandbox-send gate passes.
- `idempotency_key` and `content_sha256` are validated even in preview so the final production shape does not drift.
- Audit stores target ref, connector, connector stage, content hash, idempotency key hash/ref, and status; it must not store raw message body.
**Acceptance gate:**
- MCP tool list can include `isphere_send_message` only if it is preview/dry-run blocked from production.
- Preview test passes and proves no connector side effects.
- Production request is rejected with a clear blocked status.
- Standard verification still passes with deterministic no-send behavior.
**Stop condition:**
- If target refs cannot be represented without raw local evidence values, stop and add a target-ref design note before coding production-facing arguments.
**R6b Result:**
- Added preview-only `isphere_send_message` in `internal/tools/isphere_send_message.go`.
- Registered the tool in `internal/mcpserver/server.go`.
- Added TDD tests proving preview returns `send_status="planned"` with no raw content leak, production returns `send_status="blocked"` with no side effects, and content hash mismatch is rejected.
- Updated `scripts/verify-go-mcp.ps1` to verify the 9-tool MCP surface, send preview behavior, and `production_send_enabled=false`.
- Preview audit appends JSONL through `ISPHERE_SEND_AUDIT_PATH` or the default ignored `runs/send-audit/send-message-preview.jsonl` path. Audit stores content hash and idempotency-key hash, not raw body or raw key.
- Decision: next node is R6c online sandbox-send evidence gate. Production send and send-file remain blocked.
---
### R6c: Online sandbox-send evidence gate
**Purpose:** Collect or prepare the minimum online sandbox evidence needed before enabling any production send path.
**Files:**
- Create or modify: `docs/source-discovery/2026-07-10-send-sandbox-gate.md`
- Modify optional recorder/helper scripts only if a non-mutating or operator-approved sandbox-send evidence package is required.
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/source-discovery/capability-source-matrix.md`.
**Evidence required:**
- One explicit sandbox target only.
- One content hash only.
- One idempotency key only.
- Observed success/ack or local sent-record evidence.
- Duplicate retry with the same idempotency key is rejected locally.
- Captured evidence contains method names, argument shape, IDs/hashes, status booleans, and refs only; no raw message body needs to be committed.
**Acceptance gate:**
- A clear decision says whether production `isphere_send_message` can start, remains blocked, or needs a new online package.
- No production send behavior is enabled by this node unless the evidence gate has passed.
**Stop condition:**
- If the environment cannot log in or cannot use a sandbox target, stop and request a returned sandbox evidence package rather than implementing production send.
**R6c Result:**
- Created `docs/source-discovery/2026-07-10-send-sandbox-gate.md`.
- Added `scripts/package-send-sandbox-gate.ps1`.
- Added `scripts/verify-send-sandbox-gate-package.ps1`.
- Generated and locally verified `runs/send-sandbox-gate-package.zip`.
- The package includes the read-only live probe recorder plus before/after recorder batch files, sandbox input template, SHA256 helper, expected-return checklist, return-zip script, `RUN-RECORDING-SUITE.bat`, numbered `01/02/03` step scripts, and `RECORDING-PACKAGE-MANIFEST.json`.
- Added `cmd/isphere-capability-smoke`, `scripts/package-send-capability-test.ps1`, and `scripts/verify-send-capability-test-package.ps1`.
- Generated and locally verified the companion offline-safe package `runs/send-capability-test-package.zip`; it embeds `isphere-capability-smoke.exe` and `runs/win-helper/ISphereWinHelper.exe`, verifies the nine-tool MCP surface, fixture-backed receive/search/file-list behavior, send preview, and `production_send_enabled=false`.
- This local slice does not pass the online evidence gate because the local machine cannot log in. It only prepares the exact package needed for an online/internal sandbox operator.
- Decision: next node is R6d returned sandbox evidence intake after the package is run and returned. Production `isphere_send_message` and `isphere_send_file` remain blocked.
---
### R6d: Returned sandbox evidence intake
**Purpose:** Analyze the returned R6c online sandbox-send evidence package and decide whether production send implementation can start.
**Files:**
- Create: `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`
- Modify: this roadmap, `docs/current-status-card.md`, and `docs/source-discovery/capability-source-matrix.md`.
**Evidence to check:**
- Completed `SANDBOX-SEND-INPUTS.json`.
- Before recorder output.
- After recorder output.
- `success_ack_or_sent_record`.
- Confirmation that no second send was made.
**Acceptance gate:**
- If success/ack or sent-record evidence and duplicate-send prevention are proven, allow the later R6 production implementation node to start.
- If any item is missing, keep production send blocked and request a corrected returned package.
**Stop condition:**
- Do not infer success from screenshots or vague operator notes alone. Require the structured returned package fields and before/after recorder output.
**R6d Result:**
- Added `scripts/validate-returned-send-sandbox-package.ps1`.
- Added `scripts/test-validate-returned-send-sandbox-package.ps1`.
- Created `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`.
- Validated `C:\Users\zhaoy\Downloads\1631.zip` as partial evidence:
- `partial_evidence_usable=true`;
- `r6d_gate_pass=false`;
- capability test passed with 9 tools;
- production send remained disabled;
- content hash matched;
- idempotency key, operator observed success, success record, and no-second-send confirmation were present;
- after-recorder output and manual send timestamps were missing.
- Decision: use the returned package to proceed with sandbox-only connector shell and idempotency/audit hardening, but keep production `isphere_send_message` blocked.
---
### R6e: Sandbox-only send connector shell and idempotency audit hardening
**Purpose:** Use R6d partial evidence to build the non-production connector control plane before any real send connector is enabled.
**Files:**
- Modify: `internal/tools/isphere_send_message.go`
- Modify tests: `internal/tools/isphere_send_message_test.go`
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/go-mcp-runbook.md`
**Execution policy:**
- `execution_mode="preview"` remains no-send.
- `execution_mode="production"` remains blocked.
- Add or harden idempotency/audit state so duplicate idempotency keys can be detected before any future production connector is attached.
- If a connector interface is introduced, tests must use a fake connector only.
- No helper click/type/send op, no upload, no hook, no network replay.
**Acceptance gate:**
- Duplicate idempotency-key behavior is tested.
- Audit records remain redacted and do not store raw message body.
- Standard MCP smoke still reports `production_send_enabled=false`.
**Stop condition:**
- Do not wire a real send entrypoint until a full returned package or controlled online sandbox connector test is available.
**R6e Result:**
- Added `SendMessageIdempotencyStore` plus file-backed `fileSendMessageIdempotencyStore`.
- Added `ISPHERE_SEND_IDEMPOTENCY_PATH` for JSONL idempotency state.
- Added `RegisterISphereSendMessageToolWithState` so tests and future connector work can inject fake audit/idempotency state.
- `isphere_send_message` now returns `connector_mode="sandbox-only-shell"` and `production_send_enabled=false`.
- Duplicate idempotency key reuse for the same target/content is reported with `duplicate_detected=true` and no side effects.
- Conflicting idempotency key reuse for different content/target is blocked with `conflict_detected=true`, `send_status="blocked"`, and no side effects.
- `scripts/verify-go-mcp.ps1` and `cmd/isphere-capability-smoke` now use fresh per-run send audit/idempotency state so repeated smokes remain deterministic.
- Decision: next node is R6f fake/sandbox connector contract. Production send remains blocked.
---
### R6f: Fake/sandbox send connector contract
**Purpose:** Define the connector interface and fake connector behavior needed before any real B-route send entrypoint is attached.
**Files:**
- Modify: `internal/tools/isphere_send_message.go`
- Modify tests: `internal/tools/isphere_send_message_test.go`
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/go-mcp-runbook.md`
**Execution policy:**
- The fake connector may simulate accepted/ack/failed outcomes only inside tests.
- Runtime MCP `execution_mode="production"` remains blocked unless an explicit test-only/fake injection is used.
- No real helper send op, no UI click/type, no network/protocol send, no upload.
**Acceptance gate:**
- Fake connector tests cover accepted, failed, duplicate, and idempotency-conflict paths.
- The production MCP server still reports `production_send_enabled=false`.
- Audit records keep raw message body and raw idempotency key out of JSONL.
**Stop condition:**
- If a real connector is introduced before fake/sandbox contract tests pass, stop and revert that part.
---
### R6: Send-message preview and production path
**Purpose:** Implement production `isphere_send_message` after R6d analyzes returned sandbox evidence and proves success/ack behavior.
**Files:**
- Create or modify: `internal/tools/isphere_send_message.go`
- Create or modify tests: `internal/tools/isphere_send_message_test.go`
- Modify: `internal/mcpserver/server.go`, `internal/mcpserver/server_test.go`
- Modify: `scripts/verify-go-mcp.ps1`
- Modify docs: `docs/go-mcp-runbook.md`, this roadmap, `docs/current-status-card.md`
**Execution policy:**
- `execution_mode="preview"` returns `send_status="planned"` and does not send.
- `execution_mode="production"` requires `idempotency_key`.
- Audit includes `content_sha256`, target ref, execution mode, and result.
**Acceptance gate:**
- Preview test passes with no connector side effects.
- Production test passes only against a fake or validated sandbox connector.
- No production send is exposed unless connector evidence is complete.
---
### R7: Send-file connector discovery
**Purpose:** Determine whether file sending can reuse the send-message connector or needs a separate upload/file-transfer path.
**Files:**
- Create: `docs/source-discovery/2026-07-10-send-file-connector-selection.md`
- Modify: this roadmap and `docs/current-status-card.md`
**Required evidence:**
- File selection/upload entry point.
- Size/type limits.
- Target resolution.
- Preview metadata.
- Success confirmation.
**Acceptance gate:**
- One connector path is selected for R8, or send-file remains blocked behind an exact evidence request.
---
### R8: Send-file preview and production path
**Purpose:** Implement `isphere_send_file` after R7 validates the connector.
**Files:**
- Create or modify: `internal/tools/isphere_send_file.go`
- Create or modify tests: `internal/tools/isphere_send_file_test.go`
- Modify: `internal/mcpserver/server.go`, `internal/mcpserver/server_test.go`
- Modify verification and docs.
**Execution policy:**
- Preview computes `file_sha256`, `file_size_bytes`, target display, and `send_status="planned"`.
- Production requires `idempotency_key` and a validated connector.
- File path must be inside an allowed outbound directory.
**Acceptance gate:**
- Preview and fake/sandbox production tests pass.
- No arbitrary local file exfiltration behavior is introduced.
---
### R9: End-to-end digital employee acceptance
**Purpose:** Prove the four business goals together in one operator scenario.
**Scenario:**
1. Search contact.
2. Search group.
3. Receive recent messages.
4. List/download a received file if download is available.
5. Preview sending a message.
6. Preview sending a file if send-file is available.
**Acceptance gate:**
```powershell
git diff --check
go test ./...
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
```
Optional sandbox verification may be added only when the connector evidence is validated.
---
## Self-Review
- Spec coverage: all four user goals are represented: search contacts, search groups, receive/send messages, and receive/send files.
- Placeholder scan: no node uses placeholder markers; blocked work has exact evidence requests and stop conditions.
- Type consistency: current implemented tool names match `docs/mcp-core-tools-contract.md`: `isphere_search_contacts`, `isphere_search_groups`, `isphere_receive_messages`, `isphere_receive_files`, `isphere_send_message`, and `isphere_send_file`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
# A-Route RPA Send Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`codex/a-route-rpa-send` 分支实现 A 方案,并在合并到 `main` 后继续校准计划:通过受控 UI Automation 路线让 `isphere_send_message` 能配置为真实 UI 写入并点击发送;同时明确这只证明 UI 动作,不等于业务送达,而且 A-route/RPA 是备选兜底,不是主线。
**Architecture:** Go MCP 继续保留稳定 `isphere_send_message` 接口、hash、idempotency、audit。新增 A-route connector 调用 C# `ISphereWinHelper` 的 UIA action opC# helper 根据窗口句柄、发送框 AutomationId、发送按钮 AutomationId 写入文本并点击。B-route sidecar 仍是主路线,但当前不可测时先实现 A-route 功能兜底。
**Tech Stack:** Go 1.23.4, github.com/modelcontextprotocol/go-sdk, Windows PowerShell, .NET Framework C# helper, Windows UI Automation.
## Global Constraints
- Repository root: `E:\coding\codex\isphere-ai-bridge`.
- Original implementation branch: `codex/a-route-rpa-send`; merged branch for follow-up documentation and plan correction: `main`.
- Do not use `rg`; use `git ls-files`, `ag`, `grep -R`, or PowerShell commands.
- Local machine cannot log in; use synthetic WinForms/UIA verification for local tests.
- User explicitly removed human approval as a product gate for A-route; do not add approval IDs or approval queues in this plan.
- Keep audit redaction: do not store raw message body or raw idempotency key in committed docs/audit tests.
- Real UI action requires explicit env configuration; default MCP behavior remains preview/blocked.
- Route priority must stay B-route first: running-client sidecar / in-process connector / existing API is the primary product route; A-route/RPA only keeps a fallback path alive while B-route is blocked or untestable.
- Helper fields `clicked_ui=true` and `sent_message=true` mean the helper wrote the editor and invoked/clicked the send button. They must not be interpreted as server acceptance, receiver delivery, or sent-record proof.
---
## Task 1: Go connector must carry raw content to action connector
**Files:**
- Modify: `internal/tools/send_message_connector.go`
- Modify: `internal/tools/isphere_send_message.go`
- Modify: `internal/tools/isphere_send_message_test.go`
**Interfaces:**
- Produces: `SendMessageConnectorRequest.ContentText string` for connector execution only.
- Audit and response still omit raw content.
- [x] Write failing test proving injected connector receives raw `ContentText` while response/audit do not leak it.
- [x] Run focused test and confirm failure.
- [x] Add `ContentText` to normalized args and connector request.
- [x] Run focused send-message tests.
## Task 2: Go A-route UIA connector adapter
**Files:**
- Create: `internal/tools/send_message_uia_adapter.go`
- Create: `internal/tools/send_message_uia_adapter_test.go`
**Interfaces:**
- Produces: `type UiaSendMessageAdapterConfig struct { HelperPath string; TimeoutSeconds int; Hwnd string; SendEditorAutomationID string; SendButtonAutomationID string; Mode string }`.
- Produces: `NewUiaSendMessageConnector(config UiaSendMessageAdapterConfig, caller UiaHelperCaller) SendMessageConnector`.
- Helper op: `uia_send_message`.
- [x] Write failing tests for missing config and successful helper call using a fake helper caller.
- [x] Implement adapter request mapping.
- [x] Run focused adapter tests.
## Task 3: C# WinHelper UIA send action
**Files:**
- Create: `native/ISphereWinHelper/UiaSendAction.cs`
- Modify: `native/ISphereWinHelper/Program.cs`
- Modify: `scripts/verify-win-helper.ps1`
**Interfaces:**
- Helper op `uia_send_message` args: `hwnd`, `send_editor_automation_id`, `send_button_automation_id`, `content_text`, `content_sha256`, `target_ref`.
- Helper data: `action_mode="uia_send_message"`, `typed_text=true`, `clicked_ui=true`, `sent_message=true`, `content_sha256`, `target_ref`, `editor_found`, `button_found`.
- [x] Add synthetic WinForms verification that starts a form with `rtbSendMessage`, `btnSend`, invokes `uia_send_message`, and verifies button-click side effect in a label.
- [x] Implement `UiaSendAction` with ValuePattern/Win32 fallback and InvokePattern/click fallback.
- [x] Run `scripts\verify-win-helper.ps1`.
## Task 4: Wire A-route connector into MCP env config
**Files:**
- Modify: `internal/tools/isphere_send_message.go`
- Modify: `internal/mcpserver/server.go`
- Modify: `scripts/verify-go-mcp.ps1`
- Modify: `docs/go-mcp-runbook.md`
- Modify: `docs/current-status-card.md`
**Interfaces:**
- Env `ISPHERE_SEND_CONNECTOR_MODE=uia_rpa` enables A-route connector.
- Env `ISPHERE_SEND_UIA_HWND`, `ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID`, `ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID`, optional `ISPHERE_SEND_UIA_HELPER_PATH` configure the action.
- Default env remains no real send.
- [x] Add config loader and tests around production connector availability.
- [x] Keep standard smoke deterministic with env cleared.
- [x] Add server env routing test; helper synthetic UIA smoke covers the local write/click action.
- [x] Update docs with minimal usage.
## Task 5: Verification, commit, push
- [x] Run `git diff --check`.
- [x] Run `go test ./...`.
- [x] Run `go build ./cmd/isphere-mcp` then remove root binary.
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1`.
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1`.
- [x] Commit and push branch `codex/a-route-rpa-send`.
## Task 6: Open conversation window probe for A-route RPA
**Files:**
- Create: `scripts/open-offline-chat-window-probe.ps1`
- Create: `scripts/test-open-offline-chat-window-probe.ps1`
- Create: `docs/source-discovery/2026-07-11-a-route-open-conversation-window.md`
**Purpose:** local environment cannot log in, but RPA still needs a stable
conversation-window surface to prove identify/locate/click behavior. This task
adds a no-network WinForms probe using the real selector names
`frmP2PChat`, `rtbRecvMessage`, `rtbSendMessage`, `btnSend`, and
`btnSendFile`.
- [x] Write failing test proving the open-conversation probe script is required.
- [x] Implement the offline chat-window probe.
- [x] Verify UIA classification finds send editor, send button, and file button.
- [x] Verify `-ProbeClick` writes text and clicks `btnSend`.
- [x] Verify marker says `sent_real_message=false` and `uploaded_real_file=false`.
- [x] Document the then-current direct-construction blocker; Task 7 supersedes this by opening the real `frmP2PChat` with a fuller offline dependency harness.
## Task 7: Replace synthetic-only proof with real offline `frmP2PChat`
**Files:**
- Create: `scripts/open-offline-real-frmP2PChat.ps1`
- Create: `scripts/extract-frmP2PChat-il-risk.ps1`
- Create: `scripts/test-extract-frmP2PChat-il-risk.ps1`
- Create: `scripts/test-open-offline-real-frmP2PChat-contract.ps1`
- Create: `docs/source-discovery/2026-07-11-a-route-open-real-chat-window.md`
**Purpose:** local machine still cannot log in, but RPA validation should run
against a real client chat form instead of only a synthetic WinForms probe.
- [x] Extract constructor/load IL risks for `frmP2PChat`.
- [x] Build an offline dependency harness that can construct and show the real `frmP2PChat`.
- [x] Confirm UIA root `automation_id=frmP2PChat`.
- [x] Confirm send editor `automation_id=rtbSendMessage`.
- [x] Confirm send button `automation_id=btnSend`.
- [x] Confirm receive area `automation_id=rtbRecvMessage`.
- [x] Record that the real offline window shows the expected offline-send blocker.
- [x] Merge and push the real-window branch to `main`.
## Task 8: Corrected next loop after main merge
**Business status after Task 7:**
- Message send: UI write/click path exists; online delivery is not verified.
- File send: preview-only; no real upload path is implemented.
- Receive/search: unchanged from R14; basic digital-employee read/search remains usable through local-readonly evidence paths.
**Next implementation slices:**
- [ ] Re-check B-route sidecar / in-process connector / existing API feasibility before promoting any RPA path.
- [ ] Add a real-window A-route smoke that opens `frmP2PChat`, passes its HWND into the MCP `uia_rpa` connector, writes a unique text, clicks `btnSend`, and records sanitized UI evidence; treat this as backup readiness only.
- [ ] Add an online/logged-in verification package for message send: first target B-route evidence; include A-route evidence only as a fallback comparison path.
- [ ] Keep default `verify-go-mcp.ps1` preview/blocked unless explicit A-route env is set, so CI does not depend on a desktop login.
- [ ] Start a separate file-send RPA locator node: identify the real toolbar/menu path for file attach because `btnSendFile` was not exposed in the real offline UIA dump.
- [ ] Only after file locator proof, add file upload action and online file-send evidence package.
- [x] Create `docs/reports/2026-07-12-main-business-status-correction.md` separating “UI action ready” from “business delivery ready”.

View File

@@ -0,0 +1,335 @@
# B-Route Bridge Next Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Push the primary B-route from offline reverse evidence into the selected `in_process_adapter_research` branch, while keeping any external sidecar work preview/probe-only and not enabling real send.
**Architecture:** Keep the Go MCP tool contracts stable. B-route remains the primary product route: reuse the logged-in `IMPlatformClient.exe` runtime through a narrow bridge. Offline reverse now rules out existing IPC/plugin as the next first step, so a dedicated in-process adapter research plan is required before any production send. External sidecars may only do self-check/probe/preview. A-route/RPA remains backup-only and should be used only for UI action smoke or when B-route is proven blocked.
**Tech Stack:** Go 1.23.4, github.com/modelcontextprotocol/go-sdk, Windows PowerShell, .NET Framework C# helper/sidecar, stdin/stdout JSON protocols, Windows UI Automation only as fallback.
## Global Constraints
- Repository root: `E:\coding\codex\isphere-ai-bridge`.
- Active branch for execution should be a new `codex/` branch, not `main` directly.
- Do not use `rg`; use `git ls-files`, `ag`, `grep -R`, or PowerShell commands.
- Local machine cannot log in to iSphere; local work must use static analysis, fake sidecar processes, synthetic fixtures, or offline real-window UI only.
- B-route is primary: running-client sidecar / in-process connector / existing API comes before A-route/RPA.
- A-route/RPA is backup-only: keep it runnable, but do not make it the default product architecture.
- No real send/file upload in this plan. Stop at bridge feasibility, self_check, runtime probe, and dry-run preview.
- Keep audit redaction: do not store raw message body, raw idempotency key, raw file contents, or raw local file paths in committed outputs.
---
## Offline reverse result amendment
`docs/superpowers/plans/2026-07-12-offline-broute-reverse-plan.md` has now produced the offline reverse decision set:
- `docs/source-discovery/2026-07-12-offline-broute-reverse-index.md`
- `docs/source-discovery/2026-07-12-broute-text-send-static-map.md`
- `docs/source-discovery/2026-07-12-broute-file-send-static-map.md`
- `docs/source-discovery/2026-07-12-broute-bridge-static-map.md`
- `docs/source-discovery/2026-07-12-offline-broute-reverse-decision.md`
Decision correction: B-route remains primary, but **existing IPC/local service** and **plugin extension** are not confirmed usable. The selected next branch is `in_process_adapter_research`. External sidecar work is allowed only as preview/probe plumbing until an in-process bridge is selected. A-route/RPA remains backup-only.
## Current evidence summary
The current `main` state already has:
- `isphere_send_message` MCP contract, preview/dry-run metadata, audit, idempotency, and connector abstraction.
- `internal/tools/send_message_broute_adapter.go` with B-route `disabled` and `dry_run_contract` modes only.
- `internal/tools/send_message_uia_adapter.go` wired from env only for `ISPHERE_SEND_CONNECTOR_MODE=uia_rpa`.
- C# WinHelper `probe_client_runtime`, `probe_send_entrypoints`, `probe_send_uia_controls`, and `uia_send_message`.
- Offline real `frmP2PChat` opener for RPA backup validation.
- No B-route env wiring, no process-backed B-route sidecar client, no in-process bridge transport decision, and no real send/file-upload connector.
Important technical conclusion: an external C# process cannot automatically reuse the logged-in static runtime of `IMPlatformClient.exe`. Offline reverse has now made the transport decision for this evidence set: existing API/IPC and plugin extension are not confirmed; the next B-route node is a separate `in_process_adapter_research` plan. Do not build production send/file upload in this plan.
---
## Task 1: B-route bridge transport feasibility report
**Files:**
- Create: `docs/source-discovery/2026-07-12-b-route-bridge-feasibility.md`
- Modify: `docs/source-discovery/capability-source-matrix.md`
**Interfaces:**
- Consumes: existing docs `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`, `docs/source-discovery/2026-07-10-send-connector-preflight.md`, and current Go/C# connector files.
- Produces: a route decision table with exact choices: `existing_ipc`, `plugin_extension`, `in_process_adapter`, `external_dry_run_only`, or `fallback_rpa`.
- [ ] Re-read current B-route evidence.
Run:
```powershell
Get-Content -LiteralPath docs\source-discovery\2026-07-10-send-sidecar-b-first-plan.md
Get-Content -LiteralPath docs\source-discovery\2026-07-10-send-connector-preflight.md
Get-Content -LiteralPath internal\tools\send_message_broute_adapter.go
Get-Content -LiteralPath internal\tools\send_message_uia_adapter.go
```
Expected: confirm B-route entrypoint names exist, but no bridge transport is implemented.
- [ ] Write the feasibility report with this required conclusion block:
```markdown
## Decision
B-route remains primary. The immediate next implementation is not production send; it is bridge transport selection plus dry-run sidecar plumbing.
| Candidate | Can reuse logged-in runtime? | Local feasibility | Next action |
| --- | --- | --- | --- |
| existing_ipc/local_service | not confirmed | offline bridge map found no send/file API | park unless new online evidence appears |
| plugin_extension | not confirmed | plugin evidence is UI/app-store/context-menu oriented | park unless a concrete local load contract appears |
| in_process_adapter | possible but requires explicit bridge mechanism | selected by offline decision | create dedicated research plan before coding |
| external_dry_run_only | cannot reuse logged-in static runtime | useful for protocol/client tests only | keep preview/probe-only boundary |
| fallback_rpa | can drive UI only | backup-ready for UI action smoke | keep as backup, not primary |
```
- [ ] Update `capability-source-matrix.md` so the next slice says: “B-route bridge transport feasibility and dry-run sidecar boundary”, not “RPA send”.
- [ ] Verify docs.
Run:
```powershell
git diff --check
```
Expected: exit 0.
### 2026-07-12 plan correction
Task 1 is now satisfied by the offline reverse reports listed above. Before executing Tasks 2-5, create or approve a new `in_process_adapter_research` plan. Tasks 2-4 may still be used later for non-mutating sidecar preview plumbing, but they do not solve production send by themselves.
## Task 2: Add B-route connector env selection without starting real send
**Files:**
- Modify: `internal/tools/send_message_uia_adapter.go` or split env loading into `internal/tools/send_message_connector_env.go`
- Modify: `internal/tools/send_message_uia_adapter_test.go` or create `internal/tools/send_message_connector_env_test.go`
- Modify: `docs/go-mcp-runbook.md`
**Interfaces:**
- Consumes: `NewBRouteSendMessageConnector(BRouteSendAdapterConfig)`.
- Produces: `NewSendMessageConnectorFromEnv()` supports:
- `ISPHERE_SEND_CONNECTOR_MODE=broute_dry_run`
- `ISPHERE_SEND_CONNECTOR_MODE=broute_sidecar`
- `ISPHERE_SEND_BROUTE_MODE=disabled|dry_run_contract|sidecar_preview`
- `ISPHERE_SEND_BROUTE_SIDECAR_EXE=<path>`
- `ISPHERE_SEND_BROUTE_TIMEOUT_SECONDS=<positive int>`
- [ ] Write failing env-loader tests.
Test cases:
```text
mode empty -> nil connector
mode uia_rpa -> existing UIA connector
mode broute_dry_run -> B-route connector mode dry_run_contract
mode broute_sidecar without sidecar exe -> connector returns blocked sidecar config error, not panic
```
Run:
```powershell
go test ./internal/tools -run "TestNewSendMessageConnectorFromEnv" -count=1
```
Expected before implementation: fail for B-route modes.
- [ ] Implement env parsing with no real send.
Required behavior:
```text
broute_dry_run -> NewBRouteSendMessageConnector(BRouteSendAdapterConfig{Mode:"dry_run_contract"})
broute_sidecar -> NewBRouteSendMessageConnector(BRouteSendAdapterConfig{Mode:"sidecar_preview", SidecarPath: env path, TimeoutSeconds: parsed timeout})
```
- [ ] Run focused tests.
```powershell
go test ./internal/tools -run "TestNewSendMessageConnectorFromEnv|TestBRoute" -count=1
```
Expected: pass.
## Task 3: Process-backed B-route sidecar client boundary
**Files:**
- Create: `internal/broute/send_sidecar_client.go`
- Create: `internal/broute/send_sidecar_client_test.go`
- Modify: `internal/tools/send_message_broute_adapter.go`
- Modify: `internal/tools/send_message_broute_adapter_test.go`
**Interfaces:**
- Produces protocol: `isphere.broute.send.v1`.
- Produces sidecar ops:
- `self_check`
- `probe_runtime`
- `preview_send_message`
- No op named `send_message` in this task.
- [ ] Write fake-process tests for `internal/broute`.
The fake sidecar must accept stdin JSON and return:
```json
{"protocol":"isphere.broute.send.v1","request_id":"...","op":"preview_send_message","ok":true,"data":{"connector_mode":"broute-sidecar-preview","would_send":false,"entrypoint":"AppContextManager.SendTxtMessageByJid","target_ref":"direct:demo","content_sha256":"..."}}
```
- [ ] Implement the client with timeout, stderr capture, protocol/op/request_id validation, and structured sidecar errors.
Use `internal/msglib/sidecar_client.go` as the pattern, but keep the package separate as `internal/broute`.
- [ ] Update `send_message_broute_adapter.go` so `Mode="sidecar_preview"` calls `preview_send_message` and returns:
```text
Accepted=false
Status=blocked
ErrorCode=broute_sidecar_preview_only
ConnectorMode=broute-sidecar-preview
ProductionEnabled=false
SideEffects all false
```
- [ ] Verify no accidental production send path exists.
Run:
```powershell
go test ./internal/broute ./internal/tools -run "BRoute|Sidecar" -count=1
```
Expected: pass; no test should require iSphere login.
## Task 4: Native B-route sidecar skeleton, preview-only
**Files:**
- Create: `native/ISphereSendSidecar/Program.cs`
- Create: `native/ISphereSendSidecar/SidecarProtocol.cs`
- Create: `scripts/build-send-sidecar.ps1`
- Create: `scripts/verify-send-sidecar.ps1`
- Create: `docs/broute-send-sidecar-contract.md`
**Interfaces:**
- Native executable accepts stdin JSON and writes stdout JSON.
- Protocol: `isphere.broute.send.v1`.
- Ops implemented:
- `self_check`
- `probe_runtime`
- `preview_send_message`
- Explicitly not implemented:
- `send_message`
- `send_file`
- `upload_file`
- hooks/injection/network replay.
- [ ] Implement `self_check` returning sidecar name, version, process bitness, and `mutates_client=false`.
- [ ] Implement `probe_runtime` by reusing safe process/module discovery logic from WinHelper where possible.
- [ ] Implement `preview_send_message` as contract validation only; it must echo hashes/target refs and `would_send=false`.
- [ ] Build and verify.
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-send-sidecar.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-sidecar.ps1
go test ./...
```
Expected: all pass; sidecar verification prints only booleans/counts and no message body.
## Task 5: Online B-route evidence package v3
**Files:**
- Create: `scripts/package-broute-bridge-probe.ps1`
- Create: `scripts/verify-broute-bridge-probe-package.ps1`
- Create: `scripts/validate-returned-broute-bridge-probe.ps1`
- Create: `docs/source-discovery/2026-07-12-broute-bridge-probe-package.md`
**Interfaces:**
- Package includes:
- live probe recorder;
- send sidecar preview executable;
- `RUN-BROUTE-BRIDGE-PROBE.bat`;
- `BROUTE-BRIDGE-INPUTS.template.json`;
- return zip helper.
- [ ] Package only non-mutating probes.
- [ ] Require operator to be logged in before running.
- [ ] Validate returned package for:
- running client present;
- module availability;
- sidecar self_check passed;
- sidecar preview returned `would_send=false`;
- no real send attempted.
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\package-broute-bridge-probe.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-broute-bridge-probe-package.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\validate-returned-broute-bridge-probe.ps1 -UseFixture
```
Expected: package builds and fixture validation passes.
## Task 6: Business smoke split for send readiness
**Files:**
- Modify: `scripts/verify-business-goals-smoke.ps1`
- Modify: `docs/reports/2026-07-12-main-business-status-correction.md`
- Create or modify: `docs/reports/2026-07-12-broute-next-smoke.md`
**Interfaces:**
- Produces separate booleans:
- `send_message_preview_ready`
- `send_message_broute_bridge_preview_ready`
- `send_message_ui_action_ready`
- `send_message_business_delivery_ready`
- `send_file_preview_ready`
- `send_file_business_delivery_ready`
- [ ] Update smoke script to avoid one ambiguous `send_message_production_ready` field.
- [ ] Ensure current expected state is:
```json
{
"send_message_preview_ready": true,
"send_message_broute_bridge_preview_ready": true,
"send_message_ui_action_ready": true,
"send_message_business_delivery_ready": false,
"send_file_preview_ready": true,
"send_file_business_delivery_ready": false
}
```
- [ ] Verify.
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-business-goals-smoke.ps1
git diff --check
go test ./...
```
Expected: pass.
---
## Stop conditions
Stop and ask the business owner if any of these occur:
1. B-route bridge transport requires invasive in-process loading and no plugin/IPC path exists.
2. Online probe shows the logged-in client does not expose the expected modules/entrypoints.
3. B-route preview cannot distinguish direct contact vs group target.
4. Any step would perform real send/upload before explicit online evidence gates are ready.
5. A-route becomes the only viable path; this requires an explicit business decision because RPA is backup-only.
## Recommended next action
Start with Task 1 and Task 2 only. That creates a clear B-route bridge feasibility decision and lets the MCP server select a B-route dry-run connector from env without touching real send.

View File

@@ -0,0 +1,212 @@
# Offline B-Route Reverse Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Use offline reverse engineering to advance the primary B-route when no logged-in iSphere environment is available.
**Architecture:** Treat the available iSphere/IMPlatformClient binaries as read-only samples. Recover the send-message, send-file, plugin/IPC, and bridge-transport evidence needed to decide whether B-route can be implemented without relying on RPA. Keep A-route/RPA as backup-only and do not execute real send or upload in this plan.
**Tech Stack:** Windows PowerShell, .NET Framework IL tools, existing extracted IL files, Go 1.23.4 for repeatable analyzers, Markdown evidence reports.
## Global Constraints
- Repository root: `E:\coding\codex\isphere-ai-bridge`.
- Active branch for execution should be a new `codex/` branch, not `main` directly.
- Do not use `rg`; use `git ls-files`, `ag`, `grep -R`, or PowerShell commands.
- Tomorrow has no logged-in iSphere environment; do not wait for online evidence.
- Preserve original binaries read-only. Any extraction output must go under ignored `runs/` or committed sanitized docs/scripts only.
- B-route remains primary. A-route/RPA remains backup-only.
- No real message send, no file upload, no network replay, no process injection, and no client mutation in this plan.
- Outputs must separate verified facts from reverse-engineering hypotheses.
---
## Available offline samples
Use these local samples first:
| Sample | Purpose | SHA256 |
| --- | --- | --- |
| `runs/offline-real-client-window/full/zyl/Impp/IMPlatformClient.exe` | main managed client; text send, chat window, plugin/IPC candidates | `E2966E58360DAEEBF178410A961E2DC52748C63E9CCBD94BD6EC8434A1A09CB7` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Service.dll` | file upload service candidates | `F859DE2F34E024B5F372D95F22CBF8A018C780F24203E7A76D392E52BD90CADD` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Interface.dll` | service/interface contract candidates | `DD093CF61FAE85C2D2581F289B01AA477546712E098FEF81272FDD989E50D12D` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Model.dll` | model/parameter types | `7CBABDD23A7234D15E294C549EEEC78EFBAD6E957CD45A6E8A85309349A624A5` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.UI.dll` | UI/plugin helpers and possible send controls | `4C61212519C01D1B731ED4F20DF3411C491616F4ADA98A4529722A6AA3689E6D` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Common.dll` | shared utility and message types | `02A47C99C27C9BFE2BADC438389300C92220B07609BD87C48B4B5E1B4EE6AEB4` |
| `runs/offline-real-client-window/full/zyl/Impp/IMPP.Helper.dll` | helper abstractions | `30932D082B6678699C832C63F923E01EAD84674FAEDB613D631E034E03742FB9` |
| `runs/offline-real-client-window/full/zyl/Impp/smack.dll` | XMPP chat/send layer | `BAD0BB0591765DB153B17CECC1112B0BBDBF3B1D3370DDF973F91974E92CEC66` |
| `runs/offline-real-client-window/full/zyl/Impp/TcpFileTransfer.dll` | native/transfer candidate | `6C4874B46D1E7DC04C9B6784180603452400B881E04509D464A0D58814EFEDA7` |
| `runs/offline-real-client-window/full/zyl/Impp/HttpServerLib.dll` | local API/IPC candidate | `FFDB633216AE332B8EDFFC83E2AE546B7A80144C659FF4053FDFE4FE382A8D12` |
| `runs/offline-real-client-window/full/zyl/Impp/INetwork.dll` | network abstraction candidate | `E40BE4EE2439E4280461F4D2BADB318A5F4572428F069AB6D17B4C4F0E8CFAC9` |
| `runs/offline-real-client-window/full/zyl/Impp/IOClientNetwork.dll` | network implementation candidate | `64CA231A614771325741A5F098E52EBF20F7190E339B64962DE2EDEDCFFFF904` |
Existing IL evidence to reuse:
- `runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il/IMPlatformClient.exe.il`
- `runs/offline-chat-window/frmP2PChat-ctor.il.txt`
- `runs/offline-chat-window/frmP2PChat-ctor-null-risk.md`
---
## Task 1: Offline reverse case index
**Files:**
- Create: `docs/source-discovery/2026-07-12-offline-broute-reverse-index.md`
- Create: `scripts/build-offline-broute-reverse-index.ps1`
**Interfaces:**
- Produces: a sanitized Markdown/JSON index of sample paths, SHA256, size, timestamp, and role.
- Consumes: the sample paths listed above.
- [ ] Write `scripts/build-offline-broute-reverse-index.ps1` to hash the sample list and emit `runs/offline-broute-reverse/index.json` plus `docs/source-discovery/2026-07-12-offline-broute-reverse-index.md`.
The script must not copy or modify binaries. It should only read metadata and hashes.
- [ ] Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-offline-broute-reverse-index.ps1
```
Expected: JSON and Markdown index generated; no binary copied.
## Task 2: Text-send static call graph
**Files:**
- Create: `scripts/extract-broute-text-send-map.ps1`
- Create: `docs/source-discovery/2026-07-12-broute-text-send-static-map.md`
**Interfaces:**
- Consumes: `IMPlatformClient.exe.il`, `smack.dll`, and related managed assemblies.
- Produces: function map for:
- `AppContextManager.SendTxtMessageByJid`
- `MessageScheduling.SendChatMessage`
- `Chat.SendMessage`
- `XMPPConnection.send`
- `MessageCenter` send-related callbacks
- [ ] Extract method headers, signatures, call sites, and surrounding IL windows.
- [ ] Mark each function as one of: `confirmed_signature`, `confirmed_callsite`, `string_only`, or `not_found`.
- [ ] Write the report with a conclusion section:
```markdown
## Decision
B-route text send remains plausible only if a bridge can enter the logged-in runtime or an existing IPC/API exposes the same call. Offline IL confirms call shape, but not runtime invocability.
```
- [ ] Verify:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\extract-broute-text-send-map.ps1
git diff --check
```
Expected: exit 0 and report contains all target function names.
## Task 3: File-send/static upload call graph
**Files:**
- Create: `scripts/extract-broute-file-send-map.ps1`
- Create: `docs/source-discovery/2026-07-12-broute-file-send-static-map.md`
**Interfaces:**
- Consumes: `IMPlatformClient.exe`, `IMPP.Service.dll`, `IMPP.Interface.dll`, `IMPP.Model.dll`, `TcpFileTransfer.dll`.
- Produces: function/type map for:
- `IMPP.Client.OffLineFileSend.sendFile`
- `IMPP.Client.OffLineFileSend.trans_UploadCompleted`
- `IMPP.Service.BLL.FileTransfer.FileUpload`
- `IMPP.Service.BLL.FileTransfer.AsynFileUpload`
- `FileUploadPara`
- chat/file message send method names such as `sendFileMessage`
- [ ] Extract signatures and parameter types.
- [ ] Identify whether file send is direct upload first or chat-message first.
- [ ] Identify what local file path, server file id, file hash, and target JID values are required.
- [ ] Verify:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\extract-broute-file-send-map.ps1
git diff --check
```
Expected: report separates upload step from chat message step.
## Task 4: Bridge/IPC/plugin reverse map
**Files:**
- Create: `scripts/extract-broute-bridge-map.ps1`
- Create: `docs/source-discovery/2026-07-12-broute-bridge-static-map.md`
**Interfaces:**
- Consumes: `IMPlatformClient.exe`, `HttpServerLib.dll`, `IMPlatformClient.Web.exe`, `INetwork.dll`, `IOClientNetwork.dll`, config files, and existing live-probe `services_registry_shortcuts.json` / `network_inventory.json`.
- Produces: static evidence table for:
- localhost HTTP server routes or ports;
- named pipe strings;
- plugin loader classes or directories;
- command-line switches;
- COM/service candidates;
- update/patch extension points.
- [ ] Search strings and IL for `HttpListener`, `TcpListener`, `NamedPipe`, `Pipe`, `Plugin`, `LoadFrom`, `Assembly.Load`, `localhost`, `127.0.0.1`, `http://`, `net.pipe`, `Register`, `Route`, and `Command`.
- [ ] Report exact methods/classes where bridge candidates appear.
- [ ] Classify each candidate:
- `usable_without_login`
- `requires_logged_in_process`
- `static_only_unknown`
- `not_a_bridge`
- [ ] Verify:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\extract-broute-bridge-map.ps1
git diff --check
```
Expected: report gives a yes/no recommendation for whether B-route should pursue IPC/plugin before in-process adapter.
## Task 5: Offline bridge decision update
**Files:**
- Modify: `docs/superpowers/plans/2026-07-12-b-route-bridge-next-plan.md`
- Modify: `docs/source-discovery/capability-source-matrix.md`
- Create: `docs/source-discovery/2026-07-12-offline-broute-reverse-decision.md`
**Interfaces:**
- Consumes: reports from Tasks 1-4.
- Produces: next implementation decision:
- `existing_ipc_first`
- `plugin_extension_first`
- `in_process_adapter_research`
- `external_sidecar_preview_only`
- `fallback_rpa_only`
- [ ] Write decision using only offline evidence.
- [ ] If no bridge is found, state clearly that external sidecar can only do preview/probe unless an in-process bridge is added.
- [ ] Keep RPA marked backup-only.
## Task 6: Optional offline prototype only after decision
**Files:**
- To be added only after Task 5 decision.
**Rules:**
- If decision is `existing_ipc_first`, prototype a non-mutating client call to the local API only.
- If decision is `plugin_extension_first`, prototype plugin discovery only, not send.
- If decision is `in_process_adapter_research`, create a new explicit plan before coding.
- If decision is `external_sidecar_preview_only`, continue with the already planned preview-only B-route sidecar boundary.
- If decision is `fallback_rpa_only`, ask the user before promoting RPA beyond backup.
---
## Stop conditions
Stop and report if:
1. Static evidence shows all B-route send functions require live-only objects with no reachable bridge.
2. Bridge/plugin evidence implies invasive in-process loading and no safer IPC/API path exists.
3. File send requires server-issued upload tokens that cannot be derived offline.
4. Any next step would send a real message, upload a real file, mutate the client, attach a hook, or replay network traffic.
## Recommended immediate execution
Run Task 1 through Task 4 while offline. Do not wait for tomorrow's login evidence. After those reports exist, decide whether B-route can continue through IPC/plugin/static sidecar preview or whether RPA remains the only near-term operational path.

View File

@@ -0,0 +1,343 @@
# N14 UIA Selector Report Design
日期2026-07-09
## 1. 背景
N13 已合并到 `main`,当前已有:
- `internal/uiaselector/catalog.go`:首批只读 selector catalog。
- `internal/uiaselector/dump.go`:读取 N12R UIA dump fixture。
- `internal/uiaselector/matcher.go`:离线匹配 selector返回结构化节点摘要。
- `scripts/verify-n13-uia-selectors.ps1`:验证 N13 包和边界。
N14 的目标是把 N13 的“可定位”能力变成可交付、可审计的离线报告能力,便于内网采集包返回后快速判断 selector 稳定性和覆盖情况。
## 2. 目标
N14 建设一个离线报告生成流程:
1. 输入一个 UIA dump JSON 文件。
2. 使用 `DefaultCatalog()` 对全部 selector 执行只读匹配。
3. 输出机器可读 JSON 报告。
4. 输出人工可读 Markdown 报告。
5. 在严格模式下,如果存在未命中、歧义命中或非法 selector则返回非零退出码。
6. 报告不包含完整可见文本,只包含 `has_name``name_length` 这类派生字段。
N14 只处理离线文件,不访问正在运行的 iSphere不调用 WinHelper不新增 helper op不新增 MCP tool。
## 3. 非目标
N14 不做以下事项:
```text
连接内网 iSphere
live dump 当前窗口
新增 helper op
新增 MCP tool
点击控件
输入文本
打开会话
搜索联系人
发送消息
发送文件
接收文件
读取聊天内容
导出完整 visible text
```
N14 也不改变 N13 selector 语义。selector 的 `allowed_use` 继续固定为 `read_only_locate`
## 4. 方案比较
### 方案 A只扩展 PowerShell verifier
做法:在 `scripts/verify-n13-uia-selectors.ps1` 里临时拼接报告。
优点:改动小。
缺点报告逻辑难测试PowerShell 对 Go 内部类型不可见,后续难复用。
### 方案 B新增 Go 报告模型和 CLIPowerShell 只做包装
做法:在 `internal/uiaselector` 增加 report 生成器;新增一个小型 CLI例如 `cmd/uia-selector-report`。PowerShell 脚本只调用 CLI 并检查输出。
优点核心逻辑可单测报告结构可稳定复用CLI 可用于本地、CI 和证据包验收。
缺点:新增一个命令入口,需要少量命令行参数解析。
### 方案 C把报告能力接到 MCP 工具
做法:新增 MCP 只读工具,对外暴露 selector report。
优点:未来可被外部客户端直接调用。
缺点:现在会扩大运行面,且 N13/N14 仍处在离线证据验证阶段,不适合作为本阶段目标。
推荐采用方案 B。
## 5. 推荐设计
N14 新增一个“离线报告生成器”,由 Go package 和 CLI 组成。
核心分层:
```text
cmd/uia-selector-report/main.go
解析参数,调用 report 生成器,写 JSON/Markdown 文件,设置退出码。
internal/uiaselector/report.go
RunCatalogReport(root, catalog) -> Report
WriteReportJSON(report, path)
WriteReportMarkdown(report, path)
internal/uiaselector/report_test.go
使用 N12R redacted fixture 验证报告结构、命中数量、文本脱敏和严格模式判断。
scripts/verify-n14-uia-selector-report.ps1
运行 Go 测试,运行 CLI 生成报告,检查报告存在且不包含禁止字段或动作词。
```
N14 不需要新增独立 catalog 文件。首版直接复用 `DefaultCatalog()`
## 6. 报告数据模型
建议 JSON 报告顶层结构:
```json
{
"ok": true,
"source_dump": "internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json",
"catalog_size": 10,
"matched_count": 10,
"unmatched_count": 0,
"ambiguous_count": 0,
"generated_at_utc": "2026-07-09T00:00:00Z",
"selectors": [
{
"selector_id": "main_window",
"description": "iSphere / IMPlatformClient 主窗口",
"allowed_use": "read_only_locate",
"stability_score": 95,
"evidence": {
"capture_id": "2026-07-08-internal-sandbox-a",
"uia_path": "root"
},
"matched": true,
"match_count": 1,
"matches": [
{
"path": "root",
"control_type": "Window",
"automation_id": "frmMain",
"class_name": "WindowsForms10.Window.8.app.0.d3a00f_r29_ad1",
"framework_id": "WinForm",
"has_name": true,
"name_length": 9
}
],
"warnings": []
}
],
"warnings": []
}
```
要求:
- `selectors[].matches[]` 复用 `NodeMatch` 的安全摘要字段。
- 不输出 `name``value`、完整 visible text。
- `generated_at_utc` 由调用方注入 clock测试中使用固定时间避免 golden 文件抖动。
- `ok=false` 只用于报告生成过程失败或 catalog 非法selector 未命中不让整个报告失败,但会影响严格模式退出码。
## 7. Markdown 报告格式
Markdown 报告用于人工审阅,建议结构:
```markdown
# N14 UIA Selector Report
- Source dump: `...`
- Catalog size: 10
- Matched: 10
- Unmatched: 0
- Ambiguous: 0
## Summary
| Selector | Status | Matches | Stability | Evidence path |
| --- | --- | ---: | ---: | --- |
| main_window | matched | 1 | 95 | root |
## Selector Details
### main_window
- Description: iSphere / IMPlatformClient 主窗口
- Allowed use: read_only_locate
- Status: matched
- Match count: 1
Matches:
| Path | ControlType | AutomationID | ClassName | FrameworkID | HasName | NameLength |
| --- | --- | --- | --- | --- | --- | ---: |
| root | Window | frmMain | WindowsForms10.Window.8.app.0.d3a00f_r29_ad1 | WinForm | true | 9 |
```
Markdown 里同样禁止完整可见文本。
## 8. 严格模式
CLI 提供 `-strict` 参数。
严格模式规则:
- 报告生成失败:退出码 `1`
- 任一 selector `matched=false`:退出码 `2`
- 任一 selector `match_count>1`:退出码 `3`
- catalog 中存在 `allowed_use != read_only_locate`:退出码 `4`
- 报告文本包含禁止字段或动作词verifier 失败。
非严格模式下,未命中或歧义只写入报告和 warningsCLI 仍返回 `0`
## 9. CLI 参数
建议命令:
```powershell
go run ./cmd/uia-selector-report `
-dump internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json `
-out-json runs/n14-selector-report/n12r-selector-report.json `
-out-md runs/n14-selector-report/n12r-selector-report.md `
-strict
```
参数含义:
- `-dump`必填UIA dump JSON 路径。
- `-out-json`可选JSON 报告路径;为空则不写 JSON 文件。
- `-out-md`可选Markdown 报告路径;为空则不写 Markdown 文件。
- `-strict`:可选,启用严格退出码。
CLI 不接收 live hwnd不接收进程名不调用任何 Windows UI 自动化 API。
## 10. 测试策略
N14 implementation plan 必须采用 TDD。
核心测试:
1. `TestRunCatalogReportMatchesAllN13Selectors`
- 读取 N12R redacted fixture。
-`DefaultCatalog()` 生成报告。
- 断言 `catalog_size=10``matched_count=10``unmatched_count=0``ambiguous_count=0`
2. `TestReportDoesNotExposeVisibleText`
- 生成 JSON。
- 断言 JSON 不包含字段名 `name``value`
- 断言 JSON 包含 `has_name``name_length`
3. `TestMarkdownReportDoesNotExposeVisibleText`
- 生成 Markdown。
- 断言 Markdown 不包含已知 root visible text。
- 断言 Markdown 包含 structural fields`automation_id``control_type``class_name`
4. `TestStrictModeClassifiesFailures`
- 构造一个缺失 selector 的 catalog。
- 断言严格模式返回未命中退出分类。
5. `TestCLIWritesJSONAndMarkdownReports`
-`go run ./cmd/uia-selector-report` 或拆分后的 main runner 测试。
- 断言输出文件存在JSON 可解析Markdown 有 Summary 表格。
6. `TestReportRejectsNonReadOnlyCatalogEntries`
- 构造 `allowed_use=write` 的 selector。
- 断言报告 `ok=false` 或严格模式分类为非法 catalog。
## 11. 验证脚本
新增:
```text
scripts/verify-n14-uia-selector-report.ps1
```
职责:
1. 确认 `go` 可用。
2. 运行 `go test ./internal/uiaselector -count=1`
3. 运行 `go test ./cmd/uia-selector-report -count=1`,如果 CLI 有测试包。
4. 调用 CLI 生成报告到 `runs/n14-selector-report/`
5. 解析 JSON 报告,确认 catalog/match 统计符合 N12R fixture。
6. 扫描 JSON/Markdown 输出,确认不含:
- `"name"`
- `"value"`
- `send_after_approval`
- `send_file_after_approval`
- `receive_file_after_approval`
- `open_conversation`
- `InvokePattern`
- `ValuePattern.SetValue`
`runs/` 已在 `.gitignore` 中忽略,报告输出不进入提交。
## 12. 文件规划
后续 implementation plan 建议新增或修改:
```text
internal/uiaselector/report.go
internal/uiaselector/report_test.go
cmd/uia-selector-report/main.go
cmd/uia-selector-report/main_test.go
scripts/verify-n14-uia-selector-report.ps1
```
保持不修改:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
```
如果 implementation plan 中发现必须改 MCP/helper 文件,应停止并重新提交设计审批。
## 13. 风险和边界
主要风险:
1. 报告误包含可见文本。
- 缓解:复用 `NodeMatch`JSON/Markdown writer 做禁止字段扫描测试。
2. CLI 被误解为 live 工具。
- 缓解:命令名和参数只出现 `dump` 文件路径,不出现 hwnd/process/live。
3. strict 退出码影响 CI 使用。
- 缓解:默认非严格,验证脚本明确启用 `-strict`
4. 后续 selector 扩容导致 golden 报告抖动。
- 缓解:测试断言结构和统计,不依赖整份 golden 文本。
## 14. N14 验收条件
N14 可接受条件:
1. 可以从 N12R redacted fixture 生成 JSON 报告。
2. 可以从同一 fixture 生成 Markdown 报告。
3. 报告显示 10 个 N13 selector 全部唯一命中。
4. 报告不包含完整 visible text不包含 `name``value` 字段。
5. `-strict` 在 N12R fixture 上返回 `0`
6. verifier 脚本通过。
7. 没有新增 MCP tool没有新增 WinHelper op没有修改 live action surface。
## 15. 推荐下一步
下一步写 N14 implementation plan按以下任务拆分
1. TDD 新增 report model 和 catalog report generator。
2. TDD 新增 JSON/Markdown writer。
3. TDD 新增 CLI。
4. 新增 verifier 脚本。
5. 跑全量验证并提交。

View File

@@ -0,0 +1,266 @@
# N15 Selector Report Hardening Design
日期2026-07-09
## 1. 背景
N14 已在 `main` 上合并并推送,提供了离线 UIA selector 报告能力:
- `internal/uiaselector/report.go`报告模型、catalog report generator、JSON/Markdown renderer。
- `cmd/uia-selector-report/main.go`:离线 CLI只接受 dump 文件路径和输出报告路径。
- `scripts/verify-n14-uia-selector-report.ps1`:运行测试、生成报告并做基础安全扫描。
N14 最终 review 留下两个非阻塞建议:
1. `StrictExitAmbiguous` 分支缺少显式单测。
2. verifier 的输出安全检查仍以字符串扫描为主,并且运行 CLI 前没有清理旧输出文件。
N15 的目标是把这些 review 建议收口为一个小型加固节点,不扩展产品能力,不接触 live UI不新增 helper op不新增 MCP tool不扩展 live action surface。
## 2. 目标
N15 只做报告安全与验证加固:
1. 增加 `StrictExitAmbiguous` 显式单测,锁定 `ambiguous_count > 0` 时严格模式返回 `3`
2. 加固 `scripts/verify-n14-uia-selector-report.ps1`,避免旧报告掩盖 CLI 输出回归。
3. 增加输出文件检查:必须存在、必须是文件、大小必须大于 0。
4. 增加 JSON 字段 allowlist 检查,确认 `selectors[].matches[]` 只包含允许的结构字段。
5. 保持 N14 报告格式和 CLI 参数不变。
## 3. 非目标
N15 不做以下事项:
```text
新增 selector
修改 selector catalog
修改报告 JSON/Markdown 格式
新增 CLI 参数
新增 helper op
新增 MCP tool
连接内网 iSphere
live dump 当前窗口
使用 hwnd/process 参数
点击控件
输入文本
打开会话
搜索联系人
发送消息
发送文件
接收文件
读取聊天内容
导出完整 visible text
```
N15 也不清理本地分支。分支清理可以在 N15 合并后单独执行。
## 4. 推荐方案
采用“小范围加固”方案,只修改两个文件:
```text
internal/uiaselector/report_test.go
scripts/verify-n14-uia-selector-report.ps1
```
不修改:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
cmd/uia-selector-report/main.go
internal/uiaselector/report.go
```
理由N14 的实现逻辑已经通过最终验证和 review。N15 只需要补测试和 verifier 防回归能力,不需要改变生产代码路径。
## 5. Ambiguous strict-mode 测试设计
`internal/uiaselector/report_test.go` 中新增测试:
```text
TestStrictModeClassifiesAmbiguousMatches
```
测试构造一个最小 UIA tree
```text
root Window frmMain
child Pane duplicate
child Pane duplicate
```
测试 catalog 只包含一个 read-only selector
```text
id: duplicate_panel
allowed_use: read_only_locate
required:
automation_id: duplicate
control_type: Pane
framework_id: WinForm
```
期望:
```text
report.OK == true
report.CatalogSize == 1
report.MatchedCount == 1
report.UnmatchedCount == 0
report.AmbiguousCount == 1
report.Selectors[0].MatchCount == 2
StrictExitCode(report) == StrictExitAmbiguous
```
该测试只使用内存构造的 `Node`,不依赖 N12R fixture也不涉及 visible text。
## 6. Verifier 加固设计
### 6.1 清理旧输出
`go run ./cmd/uia-selector-report` 之前删除旧报告:
```powershell
Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue
```
目的:如果未来 CLI 回归为“退出 0 但未写新文件”,旧报告不会让后续存在性检查误通过。
### 6.2 文件存在和非空检查
替换当前 `Test-Path` 检查为更严格的文件检查:
```powershell
$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop
if (-not $jsonItem.PSIsContainer -eq $false) { ... }
if ($jsonItem.Length -le 0) { ... }
```
实际实现应避免 PowerShell 运算优先级歧义,推荐使用清晰表达:
```powershell
if ($jsonItem.PSIsContainer) { throw "JSON report path is not a file: $jsonPath" }
if ($jsonItem.Length -le 0) { throw "JSON report is empty: $jsonPath" }
```
Markdown 报告同样检查。
### 6.3 JSON match 字段 allowlist
解析 JSON 后,对每个 `selectors[].matches[]` 对象检查字段集合,只允许:
```text
path
control_type
automation_id
class_name
framework_id
has_name
name_length
```
禁止出现:
```text
name
value
text
visible_text
```
实现策略:
1. 遍历 `$report.selectors`
2. 遍历 `$selector.matches`
3. 对每个 match 的 `$match.PSObject.Properties.Name` 做集合比较。
4. 遇到不在 allowlist 中的字段立即失败。
5. 如果字段名是 `name``value`,错误信息明确指出这是 visible text 字段泄漏。
这比单纯扫描字符串更稳,因为它检查的是 JSON 对象结构。
### 6.4 保留字符串扫描
保留 N14 已有字符串扫描,用于发现:
```text
"name"
"value"
国网甘肃省电力公司
send_after_approval
send_file_after_approval
receive_file_after_approval
open_conversation
InvokePattern
ValuePattern.SetValue
```
字段 allowlist 和字符串扫描同时存在:
- allowlist 负责结构化字段泄漏。
- 字符串扫描负责已知敏感文本和动作词防线。
## 7. 测试与验证策略
N15 implementation plan 必须采用 TDD。
最小验证命令:
```powershell
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
go test ./...
```
最终验证命令:
```powershell
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
go test ./...
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go
```
最后一条命令期望无输出。
## 8. 风险与缓解
### 风险 1PowerShell 对单元素数组展开造成遍历差异
缓解:实现遍历时使用 `@($report.selectors)``@($selector.matches)`,确保单元素和多元素都按数组处理。
### 风险 2allowlist 检查误报 PowerShell 内置属性
缓解:只读取 `$match.PSObject.Properties.Name`,这返回 JSON 对象自身属性,不读取方法或扩展成员。
### 风险 3旧输出清理误删非目标文件
缓解:只删除两个明确计算出的文件路径:
```text
runs/n14-selector-report/n12r-selector-report.json
runs/n14-selector-report/n12r-selector-report.md
```
不递归删除目录。
## 9. 验收条件
N15 可接受条件:
1. `StrictExitAmbiguous` 有显式单测。
2. verifier 运行 CLI 前删除旧 JSON/Markdown 输出文件。
3. verifier 检查 JSON/Markdown 输出存在、是文件、非空。
4. verifier 对 JSON `matches[]` 执行字段 allowlist 检查。
5. verifier 继续扫描 `"name"``"value"`、已知 visible text 和动作词。
6. `go test ./...` 通过。
7. `scripts\verify-n14-uia-selector-report.ps1` 通过。
8. 不修改 helper/MCP/live action 文件。
## 10. 推荐下一步
下一步写 N15 implementation plan拆分为两个小任务
1. TDD 增加 ambiguous strict-mode 单测。
2. 加固 verifier 的旧输出清理、文件检查和 JSON 字段 allowlist。

View File

@@ -0,0 +1,113 @@
package isphere
import (
"sort"
"strings"
)
type Contact struct {
ContactID string
DisplayName string
Account string
Source string
Confidence float64
RawRef string
}
type SearchContactsQuery struct {
Query string
Limit int
}
type SearchContactsResult struct {
Contacts []Contact
}
func SearchContactsFromMessages(messages []Message, query SearchContactsQuery) SearchContactsResult {
unique := map[string]Contact{}
for _, message := range messages {
addMessageContact(unique, message.SenderID)
addMessageContact(unique, message.ReceiverID)
}
queryText := normalizedSearchText(query.Query)
contacts := make([]Contact, 0, len(unique))
for _, contact := range unique {
if contactMatchesQuery(contact, queryText) {
contacts = append(contacts, contact)
}
}
sort.Slice(contacts, func(i, j int) bool {
leftRank := contactSearchRank(contacts[i], queryText)
rightRank := contactSearchRank(contacts[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(contacts[i].ContactID)
rightID := strings.ToLower(contacts[j].ContactID)
if leftID != rightID {
return leftID < rightID
}
return contacts[i].ContactID < contacts[j].ContactID
})
limit := query.Limit
if limit <= 0 || limit > len(contacts) {
limit = len(contacts)
}
return SearchContactsResult{Contacts: contacts[:limit]}
}
func addMessageContact(unique map[string]Contact, id string) {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return
}
key := strings.ToLower(trimmed)
if _, exists := unique[key]; exists {
return
}
unique[key] = Contact{
ContactID: trimmed,
DisplayName: trimmed,
Account: trimmed,
Source: "local_readonly",
Confidence: 0.6,
RawRef: "message_jid",
}
}
func normalizedSearchText(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func contactMatchesQuery(contact Contact, queryText string) bool {
if queryText == "" {
return true
}
return strings.Contains(strings.ToLower(contact.ContactID), queryText) ||
strings.Contains(strings.ToLower(contact.DisplayName), queryText) ||
strings.Contains(strings.ToLower(contact.Account), queryText)
}
func contactSearchRank(contact Contact, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(contact.ContactID),
strings.ToLower(contact.DisplayName),
strings.ToLower(contact.Account),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -0,0 +1,38 @@
package isphere
import (
"reflect"
"testing"
)
func TestSearchContactsFromMessagesMatchesBareJIDs(t *testing.T) {
messages := []Message{
{ID: "msg-1", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "bob@imopenfire1-lanzhou"},
{ID: "msg-2", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "carol@imopenfire1-lanzhou"},
}
got := SearchContactsFromMessages(messages, SearchContactsQuery{Query: "imopenfire1", Limit: 2})
want := SearchContactsResult{Contacts: []Contact{
{ContactID: "alice@imopenfire1-lanzhou", DisplayName: "alice@imopenfire1-lanzhou", Account: "alice@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
{ContactID: "bob@imopenfire1-lanzhou", DisplayName: "bob@imopenfire1-lanzhou", Account: "bob@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
}
}
func TestSearchContactsRanksExactAndDeduplicates(t *testing.T) {
messages := []Message{
{ID: "msg-1", SenderID: "zzz-target@imopenfire1-lanzhou", ReceiverID: "target@imopenfire1-lanzhou"},
{ID: "msg-2", SenderID: "TARGET@imopenfire1-lanzhou", ReceiverID: "other@imopenfire1-lanzhou"},
}
got := SearchContactsFromMessages(messages, SearchContactsQuery{Query: "target@imopenfire1-lanzhou", Limit: 10})
want := SearchContactsResult{Contacts: []Contact{
{ContactID: "target@imopenfire1-lanzhou", DisplayName: "target@imopenfire1-lanzhou", Account: "target@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
{ContactID: "zzz-target@imopenfire1-lanzhou", DisplayName: "zzz-target@imopenfire1-lanzhou", Account: "zzz-target@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
}
}

View File

@@ -0,0 +1,89 @@
package isphere
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
func LoadEncryptedPacketLogSourceFromFile(path string) (EncryptedPacketLogSource, error) {
trimmedPath := strings.TrimSpace(path)
if trimmedPath == "" {
return EncryptedPacketLogSource{}, fmt.Errorf("packet log file path is empty")
}
lines, err := readEncryptedPacketLogLinesFromFile(trimmedPath)
if err != nil {
return EncryptedPacketLogSource{}, err
}
return EncryptedPacketLogSource{Lines: lines}, nil
}
func LoadEncryptedPacketLogSourceFromDirectory(path string) (EncryptedPacketLogSource, error) {
trimmedPath := strings.TrimSpace(path)
if trimmedPath == "" {
return EncryptedPacketLogSource{}, fmt.Errorf("packet log directory path is empty")
}
info, err := os.Stat(trimmedPath)
if err != nil {
return EncryptedPacketLogSource{}, fmt.Errorf("stat packet log directory %q: %w", trimmedPath, err)
}
if !info.IsDir() {
return EncryptedPacketLogSource{}, fmt.Errorf("packet log directory %q is not a directory", trimmedPath)
}
paths := []string{}
if err := filepath.WalkDir(trimmedPath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext == ".log" || ext == ".txt" {
paths = append(paths, path)
}
return nil
}); err != nil {
return EncryptedPacketLogSource{}, fmt.Errorf("walk packet log directory %q: %w", trimmedPath, err)
}
sort.Strings(paths)
lines := []string{}
for _, path := range paths {
fileLines, err := readEncryptedPacketLogLinesFromFile(path)
if err != nil {
return EncryptedPacketLogSource{}, err
}
lines = append(lines, fileLines...)
}
return EncryptedPacketLogSource{Lines: lines}, nil
}
func readEncryptedPacketLogLinesFromFile(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open packet log file %q: %w", path, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
lines := []string{}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
lines = append(lines, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read packet log file %q: %w", path, err)
}
return lines, nil
}

View File

@@ -0,0 +1,57 @@
package isphere
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestLoadEncryptedPacketLogSourceFromFileReadsNonEmptyLines(t *testing.T) {
path := writePacketLogFileForTest(t, "\n encrypted-line-1 \r\n\r\n\tencrypted-line-2\n")
got, err := LoadEncryptedPacketLogSourceFromFile(path)
if err != nil {
t.Fatalf("LoadEncryptedPacketLogSourceFromFile returned error: %v", err)
}
want := EncryptedPacketLogSource{Lines: []string{"encrypted-line-1", "encrypted-line-2"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("source = %#v, want %#v", got, want)
}
}
func TestLoadEncryptedPacketLogSourceFromDirectoryReadsSortedLogFiles(t *testing.T) {
root := t.TempDir()
writePacketLogFileAtPathForTest(t, root+"\\b.log", "\n line-b1 \n")
writePacketLogFileAtPathForTest(t, root+"\\a.log", "line-a1\n\nline-a2\n")
writePacketLogFileAtPathForTest(t, root+"\\nested\\c.txt", "line-c1\n")
writePacketLogFileAtPathForTest(t, root+"\\ignored.tmp", "ignored\n")
got, err := LoadEncryptedPacketLogSourceFromDirectory(root)
if err != nil {
t.Fatalf("LoadEncryptedPacketLogSourceFromDirectory returned error: %v", err)
}
want := EncryptedPacketLogSource{Lines: []string{"line-a1", "line-a2", "line-b1", "line-c1"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("source = %#v, want %#v", got, want)
}
}
func writePacketLogFileForTest(t *testing.T, content string) string {
t.Helper()
path := t.TempDir() + "\\packet.log"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write test packet log: %v", err)
}
return path
}
func writePacketLogFileAtPathForTest(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create parent for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write test packet log %s: %v", path, err)
}
}

142
internal/isphere/files.go Normal file
View File

@@ -0,0 +1,142 @@
package isphere
import (
"path/filepath"
"regexp"
"sort"
"strings"
)
type File struct {
FileID string
MessageID string
ConversationID string
FileName string
SizeBytes *int64
MimeType string
CreatedAt string
DownloadRef string
SavedPath string
SHA256 string
Source string
RawRef string
}
type ListFilesQuery struct {
ConversationID string
MessageID string
FileID string
NameContains string
Limit int
}
type ListFilesResult struct {
Files []File
}
var fileNamePattern = regexp.MustCompile(`(?i)[^\s<>:"|?*]+\.(?:docx?|xlsx?|pptx?|pdf|zip|rar|png|jpe?g|txt|csv)\b`)
func ListFilesFromMessages(messages []Message, query ListFilesQuery) ListFilesResult {
files := make([]File, 0)
for _, message := range messages {
if !messageLooksFileBacked(message) {
continue
}
fileName := extractMessageFileName(message.Text)
if fileName == "" {
continue
}
file := File{
FileID: fileID(message.ID, fileName),
MessageID: message.ID,
ConversationID: message.ConversationID,
FileName: fileName,
MimeType: mimeTypeForFileName(fileName),
CreatedAt: message.Timestamp,
Source: "local_readonly",
RawRef: "packetlog_file_transfer",
}
if fileMatchesQuery(file, query) {
files = append(files, file)
}
}
sort.Slice(files, func(i, j int) bool {
if files[i].FileID == files[j].FileID {
return files[i].MessageID < files[j].MessageID
}
return files[i].FileID < files[j].FileID
})
limit := query.Limit
if limit <= 0 || limit > len(files) {
limit = len(files)
}
return ListFilesResult{Files: files[:limit]}
}
func messageLooksFileBacked(message Message) bool {
return strings.Contains(strings.ToUpper(message.Subject), "FILE_TRANSFER") || extractMessageFileName(message.Text) != ""
}
func extractMessageFileName(text string) string {
match := fileNamePattern.FindString(strings.TrimSpace(text))
return strings.Trim(match, " .,;,。;")
}
func fileID(messageID string, fileName string) string {
if messageID == "" {
return fileName
}
return messageID + ":" + fileName
}
func fileMatchesQuery(file File, query ListFilesQuery) bool {
if query.ConversationID != "" && file.ConversationID != query.ConversationID {
return false
}
if query.MessageID != "" && file.MessageID != query.MessageID {
return false
}
if query.FileID != "" && file.FileID != query.FileID {
return false
}
nameQuery := strings.ToLower(strings.TrimSpace(query.NameContains))
if nameQuery != "" && !strings.Contains(strings.ToLower(file.FileName), nameQuery) {
return false
}
return true
}
func mimeTypeForFileName(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".doc":
return "application/msword"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
return "application/vnd.ms-excel"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".ppt":
return "application/vnd.ms-powerpoint"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case ".pdf":
return "application/pdf"
case ".zip":
return "application/zip"
case ".rar":
return "application/vnd.rar"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".txt":
return "text/plain"
case ".csv":
return "text/csv"
default:
return ""
}
}

View File

@@ -0,0 +1,31 @@
package isphere
import (
"reflect"
"testing"
)
func TestListFilesFromMessagesExtractsFileTransferCandidates(t *testing.T) {
messages := []Message{
{ID: "msg-1", ConversationID: "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou", Text: "redacted-report.docx", Timestamp: "1783423807000", Subject: "FILE_TRANSFER_CANCEL"},
{ID: "msg-2", ConversationID: "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou", Text: "plain chat", Timestamp: "1783423808000"},
{ID: "msg-3", ConversationID: "room@conference.imopenfire1-lanzhou", Text: "redacted-screenshot.png", Timestamp: "1783423809000"},
}
got := ListFilesFromMessages(messages, ListFilesQuery{NameContains: "report", Limit: 10})
want := ListFilesResult{Files: []File{
{
FileID: "msg-1:redacted-report.docx",
MessageID: "msg-1",
ConversationID: "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou",
FileName: "redacted-report.docx",
MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
CreatedAt: "1783423807000",
Source: "local_readonly",
RawRef: "packetlog_file_transfer",
},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ListFilesFromMessages() = %#v, want %#v", got, want)
}
}

119
internal/isphere/groups.go Normal file
View File

@@ -0,0 +1,119 @@
package isphere
import (
"sort"
"strings"
)
type Group struct {
GroupID string
DisplayName string
Source string
Confidence float64
RawRef string
}
type SearchGroupsQuery struct {
Query string
Limit int
}
type SearchGroupsResult struct {
Groups []Group
}
func SearchGroupsFromMessages(messages []Message, query SearchGroupsQuery) SearchGroupsResult {
unique := map[string]Group{}
for _, message := range messages {
if strings.EqualFold(message.ConversationType, "groupchat") {
addMessageGroup(unique, message.SenderID)
addMessageGroup(unique, message.ReceiverID)
continue
}
if isLikelyGroupJID(message.SenderID) {
addMessageGroup(unique, message.SenderID)
}
if isLikelyGroupJID(message.ReceiverID) {
addMessageGroup(unique, message.ReceiverID)
}
}
queryText := normalizedSearchText(query.Query)
groups := make([]Group, 0, len(unique))
for _, group := range unique {
if groupMatchesQuery(group, queryText) {
groups = append(groups, group)
}
}
sort.Slice(groups, func(i, j int) bool {
leftRank := groupSearchRank(groups[i], queryText)
rightRank := groupSearchRank(groups[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(groups[i].GroupID)
rightID := strings.ToLower(groups[j].GroupID)
if leftID != rightID {
return leftID < rightID
}
return groups[i].GroupID < groups[j].GroupID
})
limit := query.Limit
if limit <= 0 || limit > len(groups) {
limit = len(groups)
}
return SearchGroupsResult{Groups: groups[:limit]}
}
func addMessageGroup(unique map[string]Group, id string) {
trimmed := strings.TrimSpace(id)
if trimmed == "" || !isLikelyGroupJID(trimmed) {
return
}
key := strings.ToLower(trimmed)
if _, exists := unique[key]; exists {
return
}
unique[key] = Group{
GroupID: trimmed,
DisplayName: trimmed,
Source: "local_readonly",
Confidence: 0.6,
RawRef: "groupchat_jid",
}
}
func isLikelyGroupJID(id string) bool {
lower := strings.ToLower(strings.TrimSpace(id))
return strings.Contains(lower, "conference") || strings.Contains(lower, "muc") || strings.Contains(lower, "group")
}
func groupMatchesQuery(group Group, queryText string) bool {
if queryText == "" {
return true
}
return strings.Contains(strings.ToLower(group.GroupID), queryText) ||
strings.Contains(strings.ToLower(group.DisplayName), queryText)
}
func groupSearchRank(group Group, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(group.GroupID),
strings.ToLower(group.DisplayName),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -0,0 +1,39 @@
package isphere
import (
"reflect"
"testing"
)
func TestSearchGroupsFromMessagesMatchesGroupchatJIDs(t *testing.T) {
messages := []Message{
{ID: "msg-1", ConversationType: "groupchat", SenderID: "project-room@conference.imopenfire1-lanzhou", ReceiverID: "user@imopenfire1-lanzhou"},
{ID: "msg-2", ConversationType: "chat", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "bob@imopenfire1-lanzhou"},
{ID: "msg-3", ConversationType: "groupchat", SenderID: "project-room@conference.imopenfire1-lanzhou", ReceiverID: "other@imopenfire1-lanzhou"},
}
got := SearchGroupsFromMessages(messages, SearchGroupsQuery{Query: "project", Limit: 10})
want := SearchGroupsResult{Groups: []Group{
{GroupID: "project-room@conference.imopenfire1-lanzhou", DisplayName: "project-room@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want)
}
}
func TestSearchGroupsRanksExactAndDeduplicates(t *testing.T) {
messages := []Message{
{ID: "msg-1", ConversationType: "groupchat", SenderID: "zzz-project@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
{ID: "msg-2", ConversationType: "groupchat", SenderID: "project@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
{ID: "msg-3", ConversationType: "groupchat", SenderID: "PROJECT@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
}
got := SearchGroupsFromMessages(messages, SearchGroupsQuery{Query: "project@conference.imopenfire1-lanzhou", Limit: 10})
want := SearchGroupsResult{Groups: []Group{
{GroupID: "project@conference.imopenfire1-lanzhou", DisplayName: "project@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
{GroupID: "zzz-project@conference.imopenfire1-lanzhou", DisplayName: "zzz-project@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want)
}
}

View File

@@ -0,0 +1,52 @@
package logcodec
import (
"crypto/cipher"
"crypto/des"
"encoding/base64"
"errors"
"fmt"
)
var (
policyKey = []byte("hyhccdtm")
policyIV = []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
)
func DecryptLine(ciphertextBase64 string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil {
return "", fmt.Errorf("decode base64 log line: %w", err)
}
if len(ciphertext) == 0 || len(ciphertext)%des.BlockSize != 0 {
return "", fmt.Errorf("ciphertext length %d is not a positive DES block multiple", len(ciphertext))
}
block, err := des.NewCipher(policyKey)
if err != nil {
return "", fmt.Errorf("create DES cipher: %w", err)
}
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, policyIV).CryptBlocks(plain, ciphertext)
plain, err = unpadPKCS7(plain, des.BlockSize)
if err != nil {
return "", err
}
return string(plain), nil
}
func unpadPKCS7(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errors.New("pkcs7 data is empty or not block aligned")
}
pad := int(data[len(data)-1])
if pad == 0 || pad > blockSize || pad > len(data) {
return nil, fmt.Errorf("invalid pkcs7 padding length %d", pad)
}
for i := len(data) - pad; i < len(data); i++ {
if int(data[i]) != pad {
return nil, errors.New("invalid pkcs7 padding bytes")
}
}
return data[:len(data)-pad], nil
}

View File

@@ -0,0 +1,43 @@
package logcodec
import (
"crypto/cipher"
"crypto/des"
"encoding/base64"
"testing"
)
func TestDecryptLineWithKnownPolicyRoundTrip(t *testing.T) {
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\r\n2026/7/7 15:30:07\r\n<message id=\"msg-1\" from=\"sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"receiver@imopenfire1-lanzhou\" type=\"chat\"><body>redacted</body></message>"
ciphertext := encryptLineForTest(t, plaintext)
got, err := DecryptLine(ciphertext)
if err != nil {
t.Fatalf("DecryptLine returned error: %v", err)
}
if got != plaintext {
t.Fatalf("DecryptLine() = %q, want %q", got, plaintext)
}
}
func encryptLineForTest(t *testing.T, plaintext string) string {
t.Helper()
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plain := padPKCS7ForTest([]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)
}
func padPKCS7ForTest(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
}

View File

@@ -0,0 +1,104 @@
package isphere
import "math"
const reconciliationMediumTimestampWindowMillis int64 = 3000
type MessageReconciliationCandidate struct {
Source string
MessageID string
ConversationID string
SenderID string
TimestampUnixMilli int64
BodyPresent bool
AttachmentPresent bool
}
type MessageReconciliationSummary struct {
LeftCount int
RightCount int
StrongMatches int
MediumMatches int
UnmatchedLeft int
UnmatchedRight int
AutoMergeRecommended bool
}
func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary {
matchedLeft := make([]bool, len(left))
matchedRight := make([]bool, len(right))
summary := MessageReconciliationSummary{LeftCount: len(left), RightCount: len(right)}
for leftIndex, leftCandidate := range left {
if leftCandidate.MessageID == "" {
continue
}
for rightIndex, rightCandidate := range right {
if matchedRight[rightIndex] || rightCandidate.MessageID == "" {
continue
}
if leftCandidate.MessageID == rightCandidate.MessageID {
matchedLeft[leftIndex] = true
matchedRight[rightIndex] = true
summary.StrongMatches++
break
}
}
}
for leftIndex, leftCandidate := range left {
if matchedLeft[leftIndex] {
continue
}
for rightIndex, rightCandidate := range right {
if matchedRight[rightIndex] {
continue
}
if isMediumMessageMatch(leftCandidate, rightCandidate) {
matchedLeft[leftIndex] = true
matchedRight[rightIndex] = true
summary.MediumMatches++
break
}
}
}
summary.UnmatchedLeft = countFalse(matchedLeft)
summary.UnmatchedRight = countFalse(matchedRight)
summary.AutoMergeRecommended = shouldRecommendAutoMerge(summary)
return summary
}
func isMediumMessageMatch(left, right MessageReconciliationCandidate) bool {
if left.ConversationID == "" || right.ConversationID == "" || left.ConversationID != right.ConversationID {
return false
}
if left.SenderID == "" || right.SenderID == "" || left.SenderID != right.SenderID {
return false
}
if left.TimestampUnixMilli <= 0 || right.TimestampUnixMilli <= 0 {
return false
}
delta := int64(math.Abs(float64(left.TimestampUnixMilli - right.TimestampUnixMilli)))
return delta <= reconciliationMediumTimestampWindowMillis
}
func countFalse(values []bool) int {
count := 0
for _, value := range values {
if !value {
count++
}
}
return count
}
func shouldRecommendAutoMerge(summary MessageReconciliationSummary) bool {
if summary.LeftCount == 0 || summary.RightCount == 0 {
return false
}
if summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 || summary.MediumMatches != 0 {
return false
}
return summary.StrongMatches == summary.LeftCount && summary.StrongMatches == summary.RightCount
}

View File

@@ -0,0 +1,57 @@
package isphere
import "testing"
func TestReconcileMessageSourcesStrongAndMediumMatches(t *testing.T) {
left := []MessageReconciliationCandidate{
{Source: "packetlog", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 1000, BodyPresent: true},
{Source: "packetlog", MessageID: "left-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 100000, BodyPresent: true},
{Source: "packetlog", MessageID: "left-3", ConversationID: "conv-c", SenderID: "carol", TimestampUnixMilli: 200000, AttachmentPresent: true},
}
right := []MessageReconciliationCandidate{
{Source: "msglib", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 900, BodyPresent: true},
{Source: "msglib", MessageID: "right-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 102500, BodyPresent: true},
{Source: "msglib", MessageID: "right-4", ConversationID: "conv-d", SenderID: "dave", TimestampUnixMilli: 300000, AttachmentPresent: true},
}
summary := ReconcileMessageSources(left, right)
if summary.LeftCount != 3 || summary.RightCount != 3 {
t.Fatalf("counts = %d/%d", summary.LeftCount, summary.RightCount)
}
if summary.StrongMatches != 1 {
t.Fatalf("strong matches = %d, want 1", summary.StrongMatches)
}
if summary.MediumMatches != 1 {
t.Fatalf("medium matches = %d, want 1", summary.MediumMatches)
}
if summary.UnmatchedLeft != 1 || summary.UnmatchedRight != 1 {
t.Fatalf("unmatched = %d/%d, want 1/1", summary.UnmatchedLeft, summary.UnmatchedRight)
}
if summary.AutoMergeRecommended {
t.Fatalf("auto merge should remain false with weak coverage: %+v", summary)
}
}
func TestReconcileMessageSourcesAutoMergeRecommendedAtHighStrongRatio(t *testing.T) {
left := []MessageReconciliationCandidate{
{Source: "packetlog", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1},
{Source: "packetlog", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2},
{Source: "packetlog", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3},
{Source: "packetlog", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4},
}
right := []MessageReconciliationCandidate{
{Source: "msglib", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1},
{Source: "msglib", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2},
{Source: "msglib", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3},
{Source: "msglib", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4},
}
summary := ReconcileMessageSources(left, right)
if summary.StrongMatches != 4 || summary.MediumMatches != 0 || summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 {
t.Fatalf("unexpected exact summary: %+v", summary)
}
if !summary.AutoMergeRecommended {
t.Fatalf("auto merge should be recommended for complete strong match: %+v", summary)
}
}

View File

@@ -0,0 +1,98 @@
package isphere
import (
"context"
"fmt"
"strings"
"isphere-ai-bridge/internal/msglib"
)
type MsgLibMessageLister interface {
ListMessages(ctx context.Context, opts msglib.ListMessagesOptions) (msglib.ListMessagesResult, error)
}
type MsgLibMessageSource struct {
Lister MsgLibMessageLister
IncludeBody bool
IncludeAttachmentMetadata bool
}
func (s MsgLibMessageSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error) {
if s.Lister == nil {
return ReceiveMessagesResult{}, fmt.Errorf("msglib message source requires a lister")
}
result, err := s.Lister.ListMessages(ctx, msglib.ListMessagesOptions{
ConversationID: query.ConversationID,
ConversationType: "auto",
Query: query.Query,
Since: query.Since,
Limit: query.Limit,
IncludeBody: s.IncludeBody,
IncludeAttachmentMetadata: s.IncludeAttachmentMetadata,
})
if err != nil {
return ReceiveMessagesResult{}, err
}
if !result.ReadOnly {
return ReceiveMessagesResult{}, fmt.Errorf("msglib list_messages did not report read_only=true")
}
if result.RawRowsReturned {
return ReceiveMessagesResult{}, fmt.Errorf("msglib list_messages returned raw rows")
}
if result.FilePathsReturned {
return ReceiveMessagesResult{}, fmt.Errorf("msglib list_messages returned file path values")
}
messages := make([]Message, 0, len(result.Messages))
for _, item := range result.Messages {
messages = append(messages, msgLibListMessageToReceiveMessage(item))
}
return ReceiveMessagesResult{Messages: messages}, nil
}
func msgLibListMessageToReceiveMessage(item msglib.ListMessage) Message {
return Message{
ID: firstNonEmpty(item.ID, item.MessageID),
ConversationID: item.ConversationID,
ConversationType: item.ConversationType,
SenderID: item.SenderID,
ReceiverID: item.ReceiverID,
Text: firstNonEmpty(item.ContentText, item.Text),
Timestamp: firstNonEmpty(item.Timestamp, item.CreatedAt),
Subject: item.Subject,
Read: item.Read,
Source: firstNonEmpty(item.Source, "local_readonly"),
RawRef: item.RawRef,
Attachments: msgLibAttachmentsToReceiveAttachments(item.Attachments),
}
}
func msgLibAttachmentsToReceiveAttachments(items []msglib.ListMessageAttachment) []MessageAttachment {
if len(items) == 0 {
return nil
}
attachments := make([]MessageAttachment, 0, len(items))
for _, item := range items {
attachment := MessageAttachment{
FileID: item.FileID,
FileName: item.FileName,
DownloadRef: item.DownloadRef,
}
if item.SizeBytes > 0 {
size := item.SizeBytes
attachment.SizeBytes = &size
}
attachments = append(attachments, attachment)
}
return attachments
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,98 @@
package isphere
import (
"context"
"testing"
"isphere-ai-bridge/internal/msglib"
)
func TestMsgLibMessageSourceMapsListMessagesToReceiveMessages(t *testing.T) {
fake := &fakeMsgLibMessageLister{
result: msglib.ListMessagesResult{
ReadOnly: true,
MessageBodyValuesReturned: true,
RawRowsReturned: false,
FilePathsReturned: false,
SourceTables: []string{"tblMsgGroupPersonMsg"},
Messages: []msglib.ListMessage{{
MessageID: "db-msg-1",
ID: "db-msg-1",
ConversationID: "group-redacted",
ConversationType: "group",
SenderID: "sender-redacted",
SenderName: "Sender Redacted",
ReceiverID: "",
Text: "redacted body",
ContentText: "redacted body",
ContentType: "file",
Timestamp: "1783423807000",
CreatedAt: "2026-07-10T00:00:00Z",
Subject: "redacted subject",
Read: true,
Source: "local_readonly",
RawRef: "msglib:tblMsgGroupPersonMsg",
Attachments: []msglib.ListMessageAttachment{{
FileID: "file-redacted-1",
FileName: "redacted.docx",
SizeBytes: 1234,
DownloadRef: "",
}},
}},
},
}
source := MsgLibMessageSource{
Lister: fake,
IncludeBody: true,
IncludeAttachmentMetadata: true,
}
got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{
ConversationID: "group-redacted",
Query: "redacted",
Since: "2026-07-10T00:00:00Z",
Limit: 5,
})
if err != nil {
t.Fatalf("ReceiveMessages returned error: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("ListMessages calls = %d, want 1", len(fake.calls))
}
call := fake.calls[0]
if call.ConversationID != "group-redacted" || call.Query != "redacted" || call.Since != "2026-07-10T00:00:00Z" || call.Limit != 5 {
t.Fatalf("ListMessages options = %+v", call)
}
if !call.IncludeBody || !call.IncludeAttachmentMetadata {
t.Fatalf("ListMessages include flags = body:%v attachments:%v", call.IncludeBody, call.IncludeAttachmentMetadata)
}
if len(got.Messages) != 1 {
t.Fatalf("messages = %+v, want one", got.Messages)
}
msg := got.Messages[0]
if msg.ID != "db-msg-1" || msg.ConversationID != "group-redacted" || msg.ConversationType != "group" {
t.Fatalf("unexpected message identity: %+v", msg)
}
if msg.SenderID != "sender-redacted" || msg.ReceiverID != "" || msg.Text != "redacted body" || msg.Subject != "redacted subject" || !msg.Read {
t.Fatalf("unexpected message fields: %+v", msg)
}
if msg.Source != "local_readonly" || msg.RawRef != "msglib:tblMsgGroupPersonMsg" {
t.Fatalf("source refs = %q/%q", msg.Source, msg.RawRef)
}
if len(msg.Attachments) != 1 || msg.Attachments[0].FileName != "redacted.docx" || msg.Attachments[0].DownloadRef != "" {
t.Fatalf("attachments = %+v", msg.Attachments)
}
if msg.Attachments[0].SizeBytes == nil || *msg.Attachments[0].SizeBytes != 1234 {
t.Fatalf("attachment size = %+v", msg.Attachments[0].SizeBytes)
}
}
type fakeMsgLibMessageLister struct {
calls []msglib.ListMessagesOptions
result msglib.ListMessagesResult
}
func (f *fakeMsgLibMessageLister) ListMessages(_ context.Context, opts msglib.ListMessagesOptions) (msglib.ListMessagesResult, error) {
f.calls = append(f.calls, opts)
return f.result, nil
}

View File

@@ -0,0 +1,73 @@
package packetlog
import (
"encoding/xml"
"errors"
"fmt"
"strings"
)
type Message struct {
ID string
From string
To string
Type string
Body string
Subject string
SendTime string
ReceiptID string
ReceiptType string
Consumed bool
Read bool
}
type xmppMessage struct {
XMLName xml.Name `xml:"message"`
ID string `xml:"id,attr"`
From string `xml:"from,attr"`
To string `xml:"to,attr"`
Type string `xml:"type,attr"`
Body string `xml:"body"`
Subject string `xml:"subject"`
Receipt xmppReceipt `xml:"received"`
ISphere xmppISphere `xml:"isphere"`
Consumed *struct{} `xml:"consumed"`
Readed *struct{} `xml:"readed"`
}
type xmppReceipt struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
}
type xmppISphere struct {
SendTime string `xml:"sendtime,attr"`
}
func ParseMessageLog(plaintext string) (Message, error) {
start := strings.Index(plaintext, "<message")
if start < 0 {
return Message{}, errors.New("message stanza not found")
}
payload := strings.TrimSpace(plaintext[start:])
var parsed xmppMessage
if err := xml.Unmarshal([]byte(payload), &parsed); err != nil {
return Message{}, fmt.Errorf("parse xmpp message: %w", err)
}
if parsed.ID == "" {
return Message{}, errors.New("message id is empty")
}
return Message{
ID: parsed.ID,
From: parsed.From,
To: parsed.To,
Type: parsed.Type,
Body: strings.TrimSpace(parsed.Body),
Subject: strings.TrimSpace(parsed.Subject),
SendTime: parsed.ISphere.SendTime,
ReceiptID: parsed.Receipt.ID,
ReceiptType: parsed.Receipt.Type,
Consumed: parsed.Consumed != nil,
Read: parsed.Readed != nil,
}, nil
}

View File

@@ -0,0 +1,27 @@
package packetlog
import "testing"
func TestParseMessageLogExtractsXMPPMessage(t *testing.T) {
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\r\n2026/7/7 15:30:07\r\n<message id=\"2de080bf-f5cc-45e2-b229-2df6bcd4afe3\" from=\"sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"receiver@imopenfire1-lanzhou\" type=\"chat\">\r\n <body>redacted.docx</body>\r\n <received xmlns=\"urn:xmpp:receipts\" id=\"167c08c3d710489f812c13dc23d4e404\" type=\"1\" stamp=\"\" />\r\n <subject>FILE_TRANSFER_CANCEL</subject>\r\n <isphere xmlns=\"isphere.im\" type=\"1001\" sendtime=\"1783423807000\" version=\"1\">\r\n <alert type=\"0\" />\r\n </isphere>\r\n <consumed />\r\n <readed />\r\n</message>"
got, err := ParseMessageLog(plaintext)
if err != nil {
t.Fatalf("ParseMessageLog returned error: %v", err)
}
if got.ID != "2de080bf-f5cc-45e2-b229-2df6bcd4afe3" {
t.Fatalf("ID = %q", got.ID)
}
if got.From != "sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" || got.To != "receiver@imopenfire1-lanzhou" {
t.Fatalf("from/to = %q/%q", got.From, got.To)
}
if got.Body != "redacted.docx" || got.Subject != "FILE_TRANSFER_CANCEL" {
t.Fatalf("body/subject = %q/%q", got.Body, got.Subject)
}
if got.SendTime != "1783423807000" || got.ReceiptID != "167c08c3d710489f812c13dc23d4e404" || got.ReceiptType != "1" {
t.Fatalf("receipt fields = %+v", got)
}
if !got.Consumed || !got.Read {
t.Fatalf("consumed/read = %v/%v", got.Consumed, got.Read)
}
}

181
internal/isphere/source.go Normal file
View File

@@ -0,0 +1,181 @@
package isphere
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"isphere-ai-bridge/internal/isphere/logcodec"
"isphere-ai-bridge/internal/isphere/packetlog"
)
type Message struct {
ID string
ConversationID string
ConversationType string
SenderID string
ReceiverID string
Text string
Timestamp string
Subject string
ReceiptID string
Read bool
Source string
RawRef string
Attachments []MessageAttachment
}
type MessageAttachment struct {
FileID string
FileName string
SizeBytes *int64
DownloadRef string
}
type ReceiveMessagesQuery struct {
ConversationID string
Query string
Since string
Limit int
}
type ReceiveMessagesResult struct {
Messages []Message
}
type EncryptedPacketLogSource struct {
Lines []string
}
func (s EncryptedPacketLogSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error) {
since, hasSince, err := parseReceiveMessagesSince(query.Since)
if err != nil {
return ReceiveMessagesResult{}, err
}
limit := query.Limit
if limit <= 0 || limit > len(s.Lines) {
limit = len(s.Lines)
}
messages := make([]Message, 0, limit)
for index, line := range s.Lines {
select {
case <-ctx.Done():
return ReceiveMessagesResult{}, ctx.Err()
default:
}
plaintext, err := logcodec.DecryptLine(line)
if err != nil {
return ReceiveMessagesResult{}, fmt.Errorf("decrypt packet log line %d: %w", index, err)
}
parsed, err := packetlog.ParseMessageLog(plaintext)
if err != nil {
return ReceiveMessagesResult{}, fmt.Errorf("parse packet log line %d: %w", index, err)
}
message := normalizePacketMessage(parsed)
if !receiveMessageMatchesQuery(message, query) || !receiveMessageMatchesSince(message, since, hasSince) {
continue
}
messages = append(messages, message)
if len(messages) == limit {
break
}
}
return ReceiveMessagesResult{Messages: messages}, nil
}
func parseReceiveMessagesSince(value string) (time.Time, bool, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, false, nil
}
parsed, err := time.Parse(time.RFC3339Nano, trimmed)
if err != nil {
return time.Time{}, false, fmt.Errorf("parse receive messages since %q: %w", value, err)
}
return parsed.UTC(), true, nil
}
func receiveMessageMatchesQuery(message Message, query ReceiveMessagesQuery) bool {
if strings.TrimSpace(query.ConversationID) != "" && message.ConversationID != strings.TrimSpace(query.ConversationID) {
return false
}
keyword := strings.ToLower(strings.TrimSpace(query.Query))
if keyword == "" {
return true
}
values := []string{
message.ID,
message.ConversationID,
message.SenderID,
message.ReceiverID,
message.Text,
message.Subject,
message.ReceiptID,
}
for _, value := range values {
if strings.Contains(strings.ToLower(value), keyword) {
return true
}
}
return false
}
func receiveMessageMatchesSince(message Message, since time.Time, hasSince bool) bool {
if !hasSince {
return true
}
messageTime, ok := packetMessageTimestamp(message.Timestamp)
if !ok {
return false
}
return !messageTime.Before(since)
}
func packetMessageTimestamp(value string) (time.Time, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, false
}
epoch, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return time.Time{}, false
}
if epoch > 1_000_000_000_000 {
return time.UnixMilli(epoch).UTC(), true
}
return time.Unix(epoch, 0).UTC(), true
}
func normalizePacketMessage(msg packetlog.Message) Message {
sender := bareJID(msg.From)
receiver := bareJID(msg.To)
return Message{
ID: msg.ID,
ConversationID: conversationID(sender, receiver),
ConversationType: msg.Type,
SenderID: sender,
ReceiverID: receiver,
Text: msg.Body,
Timestamp: msg.SendTime,
Subject: msg.Subject,
ReceiptID: msg.ReceiptID,
Read: msg.Read,
}
}
func bareJID(value string) string {
beforeResource, _, _ := strings.Cut(value, "/")
return beforeResource
}
func conversationID(sender string, receiver string) string {
if sender == "" {
return receiver
}
if receiver == "" {
return sender
}
return sender + "|" + receiver
}

View File

@@ -0,0 +1,158 @@
package isphere
import (
"context"
"crypto/cipher"
"crypto/des"
"encoding/base64"
"testing"
)
func TestEncryptedPacketLogSourceReturnsNormalizedMessages(t *testing.T) {
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>redacted</body>
<received xmlns="urn:xmpp:receipts" id="receipt-1" type="1" stamp="" />
<subject>FILE_TRANSFER_CANCEL</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
source := EncryptedPacketLogSource{Lines: []string{encryptPacketLineForTest(t, plaintext)}}
got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{Limit: 10})
if err != nil {
t.Fatalf("ReceiveMessages returned error: %v", err)
}
if len(got.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(got.Messages))
}
msg := got.Messages[0]
if msg.ID != "msg-1" || msg.Text != "redacted" || msg.Subject != "FILE_TRANSFER_CANCEL" {
t.Fatalf("unexpected message content: %+v", msg)
}
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
t.Fatalf("conversation id = %q", msg.ConversationID)
}
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
}
if msg.Timestamp != "1783423807000" || msg.ReceiptID != "receipt-1" || !msg.Read {
t.Fatalf("metadata = %+v", msg)
}
}
func TestEncryptedPacketLogSourceFiltersByConversationAndQuery(t *testing.T) {
first := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-alpha" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>alpha status update</body>
<received xmlns="urn:xmpp:receipts" id="receipt-alpha" type="1" stamp="" />
<subject>TASK_UPDATE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
second := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:31:07
<message id="msg-beta" from="project-room@conference.imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="sender@imopenfire1-lanzhou" type="groupchat">
<body>beta budget redacted-report.docx</body>
<received xmlns="urn:xmpp:receipts" id="receipt-beta" type="1" stamp="" />
<subject>FILE_TRANSFER_CANCEL</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423867000" version="1" />
<readed />
</message>`
source := EncryptedPacketLogSource{Lines: []string{
encryptPacketLineForTest(t, first),
encryptPacketLineForTest(t, second),
}}
got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{
ConversationID: "project-room@conference.imopenfire1-lanzhou|sender@imopenfire1-lanzhou",
Query: "BUDGET",
Limit: 10,
})
if err != nil {
t.Fatalf("ReceiveMessages returned error: %v", err)
}
if len(got.Messages) != 1 {
t.Fatalf("message count = %d, want 1: %+v", len(got.Messages), got.Messages)
}
if got.Messages[0].ID != "msg-beta" {
t.Fatalf("message id = %q, want msg-beta", got.Messages[0].ID)
}
empty, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{
ConversationID: "project-room@conference.imopenfire1-lanzhou|sender@imopenfire1-lanzhou",
Query: "alpha",
Limit: 10,
})
if err != nil {
t.Fatalf("ReceiveMessages returned error for empty query: %v", err)
}
if len(empty.Messages) != 0 {
t.Fatalf("empty query returned %+v, want none", empty.Messages)
}
}
func TestEncryptedPacketLogSourceFiltersBySince(t *testing.T) {
first := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-before" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>before threshold</body>
<received xmlns="urn:xmpp:receipts" id="receipt-before" type="1" stamp="" />
<subject>TASK_UPDATE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
second := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:31:07
<message id="msg-after" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>after threshold</body>
<received xmlns="urn:xmpp:receipts" id="receipt-after" type="1" stamp="" />
<subject>TASK_UPDATE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423867000" version="1" />
<readed />
</message>`
source := EncryptedPacketLogSource{Lines: []string{
encryptPacketLineForTest(t, first),
encryptPacketLineForTest(t, second),
}}
got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{
Since: "2026-07-07T11:31:00Z",
Limit: 10,
})
if err != nil {
t.Fatalf("ReceiveMessages returned error: %v", err)
}
if len(got.Messages) != 1 || got.Messages[0].ID != "msg-after" {
t.Fatalf("messages = %+v, want only msg-after", got.Messages)
}
_, err = source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{Since: "not-a-time"})
if err == nil {
t.Fatalf("ReceiveMessages accepted invalid since")
}
}
func encryptPacketLineForTest(t *testing.T, plaintext string) string {
t.Helper()
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plain := padPacketLineForTest([]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)
}
func padPacketLineForTest(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
}

View File

@@ -1,24 +1,121 @@
package mcpserver
import (
"fmt"
"os"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/helperclient"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
"isphere-ai-bridge/internal/tools"
)
const (
ServerName = "isphere-ai-bridge"
ServerTitle = "iSphere AI Bridge"
ServerVersion = "0.1.0"
ServerName = "isphere-ai-bridge"
ServerTitle = "iSphere AI Bridge"
ServerVersion = "0.1.0"
EnvPacketLogFileKey = "ISPHERE_PACKET_LOG_FILE"
EnvPacketLogDirKey = "ISPHERE_PACKET_LOG_DIR"
EnvMsgLibSidecarExeKey = "ISPHERE_MSGLIB_SIDECAR_EXE"
EnvMsgLibSQLiteDLLKey = "ISPHERE_MSGLIB_SQLITE_DLL"
EnvMsgLibDBKey = "ISPHERE_MSGLIB_DB"
)
type msgLibClient interface {
tools.DisplayEntitySource
isphere.MsgLibMessageLister
}
var newMsgLibClient = func(cfg msglib.Config) msgLibClient {
return msglib.NewClient(cfg)
}
func NewServer() *mcp.Server {
return NewServerWithReceiveSource(isphere.EncryptedPacketLogSource{})
}
func NewServerFromEnv() (*mcp.Server, error) {
var source tools.ReceiveMessagesSource = isphere.EncryptedPacketLogSource{}
packetLogFile := strings.TrimSpace(os.Getenv(EnvPacketLogFileKey))
packetLogDir := strings.TrimSpace(os.Getenv(EnvPacketLogDirKey))
if packetLogFile != "" && packetLogDir != "" {
return nil, fmt.Errorf("configure packet log source: set only one of %s or %s", EnvPacketLogFileKey, EnvPacketLogDirKey)
}
if packetLogFile != "" {
loaded, err := isphere.LoadEncryptedPacketLogSourceFromFile(packetLogFile)
if err != nil {
return nil, fmt.Errorf("configure %s: %w", EnvPacketLogFileKey, err)
}
source = loaded
} else if packetLogDir != "" {
loaded, err := isphere.LoadEncryptedPacketLogSourceFromDirectory(packetLogDir)
if err != nil {
return nil, fmt.Errorf("configure %s: %w", EnvPacketLogDirKey, err)
}
source = loaded
}
displaySource, msglibReceiveSource, err := msgLibSourcesFromEnv()
if err != nil {
return nil, err
}
return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, tools.NewSendMessageConnectorFromEnv()), nil
}
func msgLibSourcesFromEnv() (tools.DisplayEntitySource, tools.ReceiveMessagesSource, error) {
sidecarExe := strings.TrimSpace(os.Getenv(EnvMsgLibSidecarExeKey))
sqliteDLL := strings.TrimSpace(os.Getenv(EnvMsgLibSQLiteDLLKey))
dbPath := strings.TrimSpace(os.Getenv(EnvMsgLibDBKey))
configured := 0
for _, value := range []string{sidecarExe, sqliteDLL, dbPath} {
if value != "" {
configured++
}
}
if configured == 0 {
return nil, nil, nil
}
if configured != 3 {
return nil, nil, fmt.Errorf("configure MsgLib source: set all of %s, %s, and %s, or set none", EnvMsgLibSidecarExeKey, EnvMsgLibSQLiteDLLKey, EnvMsgLibDBKey)
}
cfg, err := msglib.ConfigFromEnv()
if err != nil {
return nil, nil, fmt.Errorf("configure MsgLib source: %w", err)
}
client := newMsgLibClient(cfg)
return client, isphere.MsgLibMessageSource{
Lister: client,
IncludeBody: true,
IncludeAttachmentMetadata: true,
}, nil
}
func NewServerWithReceiveSource(source tools.ReceiveMessagesSource) *mcp.Server {
return NewServerWithSources(source, nil)
}
func NewServerWithSources(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource) *mcp.Server {
return NewServerWithSourcesAndMsgLibReceive(source, displaySource, nil)
}
func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource, msglibReceiveSource tools.ReceiveMessagesSource) *mcp.Server {
return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, nil)
}
func NewServerWithSourcesMsgLibReceiveAndSendConnector(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource, msglibReceiveSource tools.ReceiveMessagesSource, sendConnector tools.SendMessageConnector) *mcp.Server {
server := mcp.NewServer(&mcp.Implementation{
Name: ServerName,
Title: ServerTitle,
Version: ServerVersion,
}, nil)
tools.RegisterWinHelperTools(server, helperclient.Client{})
tools.RegisterISphereReadToolsWithReceiveSources(server, source, msglibReceiveSource, displaySource)
tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource)
tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource)
tools.RegisterISphereFileTools(server, source)
tools.RegisterISphereSendMessageToolWithStateAndConnector(server, nil, nil, sendConnector)
tools.RegisterISphereSendFileTool(server)
return server
}

View File

@@ -2,12 +2,22 @@ package mcpserver
import (
"context"
"crypto/cipher"
"crypto/des"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/msglib"
)
func TestNewServerConstructsMCPServer(t *testing.T) {
@@ -17,7 +27,7 @@ func TestNewServerConstructsMCPServer(t *testing.T) {
}
}
func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
func TestNewServerRegistersTenToolsWithoutCallingHelper(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server := NewServer()
@@ -52,8 +62,374 @@ func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
gotNames = append(gotNames, tool.Name)
}
sort.Strings(gotNames)
wantNames := []string{"win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
wantNames := []string{"isphere_receive_files", "isphere_receive_messages", "isphere_search_contacts", "isphere_search_groups", "isphere_send_file", "isphere_send_message", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
if !reflect.DeepEqual(gotNames, wantNames) {
t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames)
}
}
func TestNewServerFromEnvRejectsPartialMsgLibConfig(t *testing.T) {
clearMsgLibEnvForServerTest(t)
t.Setenv(EnvMsgLibSidecarExeKey, `C:\tools\MsgLibReadSidecar.exe`)
_, err := NewServerFromEnv()
if err == nil {
t.Fatal("NewServerFromEnv returned nil error for partial MsgLib config")
}
if !strings.Contains(err.Error(), EnvMsgLibSQLiteDLLKey) || !strings.Contains(err.Error(), EnvMsgLibDBKey) {
t.Fatalf("error = %v, want missing MsgLib env context", err)
}
}
func TestNewServerFromEnvWiresExplicitMsgLibReceiveSource(t *testing.T) {
clearMsgLibEnvForServerTest(t)
fake := &fakeMsgLibClientForServerTest{
listResult: msglib.ListMessagesResult{
ReadOnly: true,
MessageBodyValuesReturned: true,
RawRowsReturned: false,
FilePathsReturned: false,
SourceTables: []string{"tblPersonMsg"},
Messages: []msglib.ListMessage{{
MessageID: "db-env-msg-1",
ID: "db-env-msg-1",
ConversationID: "db-env-conversation",
ConversationType: "direct",
SenderID: "db-env-sender",
ReceiverID: "db-env-receiver",
ContentText: "db env body",
Timestamp: "1783423809000",
Source: "local_readonly",
RawRef: "msglib:tblPersonMsg",
}},
},
}
oldFactory := newMsgLibClient
newMsgLibClient = func(msglib.Config) msgLibClient {
return fake
}
defer func() { newMsgLibClient = oldFactory }()
t.Setenv(EnvMsgLibSidecarExeKey, `C:\fixture\MsgLibReadSidecar.exe`)
t.Setenv(EnvMsgLibSQLiteDLLKey, `C:\fixture\System.Data.SQLite.dll`)
t.Setenv(EnvMsgLibDBKey, `C:\fixture\MsgLib.db`)
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
defaultResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"limit": 5},
})
if err != nil {
t.Fatalf("default receive call: %v", err)
}
if defaultResult.IsError {
t.Fatalf("default receive returned error: %+v", defaultResult)
}
if len(fake.listCalls) != 0 {
t.Fatalf("default receive called MsgLib ListMessages: %+v", fake.listCalls)
}
dbResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"source_preference": "msglib_readonly", "limit": 5},
})
if err != nil {
t.Fatalf("msglib receive call: %v", err)
}
if dbResult.IsError {
t.Fatalf("msglib receive returned error: %+v", dbResult)
}
payload, err := json.Marshal(dbResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
RawRef string `json:"raw_ref"`
} `json:"messages"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 || decoded.Messages[0].ID != "db-env-msg-1" || decoded.Messages[0].RawRef != "msglib:tblPersonMsg" {
t.Fatalf("messages = %+v, want explicit MsgLib result", decoded.Messages)
}
if len(fake.listCalls) != 1 || !fake.listCalls[0].IncludeBody || !fake.listCalls[0].IncludeAttachmentMetadata {
t.Fatalf("ListMessages calls = %+v", fake.listCalls)
}
}
func TestNewServerFromEnvUsesPacketLogFile(t *testing.T) {
clearMsgLibEnvForServerTest(t)
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-env-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>redacted from env</body>
<received xmlns="urn:xmpp:receipts" id="receipt-env-1" type="1" stamp="" />
<subject>ENV_SOURCE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
filePath := writeEncryptedPacketLogFileForServerTest(t, plaintext)
t.Setenv("ISPHERE_PACKET_LOG_FILE", filePath)
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("call isphere_receive_messages: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
Text string `json:"text"`
Subject string `json:"subject"`
ConversationID string `json:"conversation_id"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id"`
Read bool `json:"read"`
} `json:"messages"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(decoded.Messages))
}
msg := decoded.Messages[0]
if msg.ID != "msg-env-1" || msg.Text != "redacted from env" || msg.Subject != "ENV_SOURCE" {
t.Fatalf("unexpected message: %+v", msg)
}
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
t.Fatalf("conversation id = %q", msg.ConversationID)
}
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" || !msg.Read {
t.Fatalf("metadata = %+v", msg)
}
}
func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) {
clearMsgLibEnvForServerTest(t)
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-dir-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>redacted from dir</body>
<received xmlns="urn:xmpp:receipts" id="receipt-dir-1" type="1" stamp="" />
<subject>DIR_SOURCE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
dirPath := writeEncryptedPacketLogDirectoryForServerTest(t, plaintext)
t.Setenv("ISPHERE_PACKET_LOG_DIR", dirPath)
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("call isphere_receive_messages: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
Text string `json:"text"`
Subject string `json:"subject"`
} `json:"messages"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(decoded.Messages))
}
msg := decoded.Messages[0]
if msg.ID != "msg-dir-1" || msg.Text != "redacted from dir" || msg.Subject != "DIR_SOURCE" {
t.Fatalf("unexpected message: %+v", msg)
}
}
func TestNewServerFromEnvWiresUiaRpaSendConnector(t *testing.T) {
clearMsgLibEnvForServerTest(t)
t.Setenv("ISPHERE_SEND_CONNECTOR_MODE", "uia_rpa")
t.Setenv("ISPHERE_SEND_UIA_HELPER_PATH", t.TempDir()+"\\missing-helper.exe")
t.Setenv("ISPHERE_SEND_UIA_HWND", "0x1234")
t.Setenv("ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID", "rtbSendMessage")
t.Setenv("ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID", "btnSend")
t.Setenv("ISPHERE_SEND_AUDIT_PATH", t.TempDir()+"\\send-audit.jsonl")
t.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", t.TempDir()+"\\send-idempotency.jsonl")
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
content := "uia rpa send through env"
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_send_message",
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": sha256HexForServerTest(content),
"idempotency_key": "idem-uia-env-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call isphere_send_message: %v", err)
}
if callResult.IsError {
t.Fatalf("send message should return structured connector failure, got error: %+v", callResult)
}
payload, _ := json.Marshal(callResult.StructuredContent)
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
ConnectorMode string `json:"connector_mode"`
ErrorCode string `json:"error_code"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "failed" || decoded.ConnectorMode != "uia-rpa" || decoded.ErrorCode != "uia_rpa_helper_error" {
t.Fatalf("production send did not route to UIA connector: %s", payload)
}
}
type fakeMsgLibClientForServerTest struct {
displayCalls []msglib.DisplayEntitiesOptions
listCalls []msglib.ListMessagesOptions
listResult msglib.ListMessagesResult
}
func (f *fakeMsgLibClientForServerTest) DisplayEntities(_ context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error) {
f.displayCalls = append(f.displayCalls, opts)
return nil, nil
}
func (f *fakeMsgLibClientForServerTest) ListMessages(_ context.Context, opts msglib.ListMessagesOptions) (msglib.ListMessagesResult, error) {
f.listCalls = append(f.listCalls, opts)
return f.listResult, nil
}
func clearMsgLibEnvForServerTest(t *testing.T) {
t.Helper()
t.Setenv(EnvMsgLibSidecarExeKey, "")
t.Setenv(EnvMsgLibSQLiteDLLKey, "")
t.Setenv(EnvMsgLibDBKey, "")
t.Setenv("ISPHERE_MSGLIB_PASSWORD", "")
}
func writeEncryptedPacketLogDirectoryForServerTest(t *testing.T, plaintext string) string {
t.Helper()
dir := t.TempDir()
line := encryptPacketLogLineForServerTest(t, plaintext)
if err := os.WriteFile(dir+"\\packet-reader-2.txt", []byte("\n"+line+"\n"), 0o600); err != nil {
t.Fatalf("write encrypted packet log directory fixture: %v", err)
}
return dir
}
func connectServerTestSession(t *testing.T, server *mcp.Server) (*mcp.ClientSession, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
serverTransport, clientTransport := mcp.NewInMemoryTransports()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx, serverTransport)
}()
client := mcp.NewClient(&mcp.Implementation{Name: "mcpserver-env-test-client", Version: "0.0.0"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
cancel()
t.Fatalf("connect client: %v", err)
}
cleanup := func() {
_ = session.Close()
cancel()
select {
case <-errCh:
case <-time.After(2 * time.Second):
t.Fatalf("server did not stop")
}
}
return session, cleanup
}
func writeEncryptedPacketLogFileForServerTest(t *testing.T, plaintext string) string {
t.Helper()
path := t.TempDir() + "\\packet-reader.log"
line := encryptPacketLogLineForServerTest(t, plaintext)
if err := os.WriteFile(path, []byte("\n"+line+"\n"), 0o600); err != nil {
t.Fatalf("write encrypted packet log file: %v", err)
}
return path
}
func encryptPacketLogLineForServerTest(t *testing.T, plaintext string) string {
t.Helper()
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plain := padPacketLogLineForServerTest([]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)
}
func padPacketLogLineForServerTest(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 sha256HexForServerTest(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,405 @@
package msglib
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync/atomic"
"time"
)
const (
Protocol = "isphere.msglib.v1"
DefaultPassword = "123"
EntityTypeContacts = "contacts"
EntityTypeGroups = "groups"
DefaultDisplayEntityLimit = 25
MaxDisplayEntityLimit = 100
DefaultListMessagesLimit = 20
MaxListMessagesLimit = 50
)
var requestCounter uint64
// Config describes the local sidecar process and copied MsgLib DB inputs.
type Config struct {
SidecarPath string
SidecarArgs []string
SQLiteDLLPath string
DBPath string
Password string
Timeout time.Duration
ExtraEnv []string
}
// ConfigFromEnv reads the C28 environment shape without wiring it into MCP tools.
func ConfigFromEnv() (Config, error) {
cfg := Config{
SidecarPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_SIDECAR_EXE")),
SQLiteDLLPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_SQLITE_DLL")),
DBPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_DB")),
Password: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_PASSWORD")),
}
if cfg.Password == "" {
cfg.Password = DefaultPassword
}
if cfg.SidecarPath == "" {
return cfg, errors.New("ISPHERE_MSGLIB_SIDECAR_EXE is not configured")
}
return cfg, nil
}
// NewClient returns a process-backed MsgLib sidecar client.
func NewClient(cfg Config) *Client {
if cfg.Password == "" {
cfg.Password = DefaultPassword
}
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Second
}
return &Client{config: cfg}
}
type Client struct {
config Config
}
type SelfCheckResult struct {
SidecarName string `json:"sidecar_name"`
SidecarVersion string `json:"sidecar_version"`
Protocol string `json:"protocol"`
Runtime string `json:"runtime"`
ProcessBits int `json:"process_bits"`
ReadOnly bool `json:"read_only"`
MutatesDB bool `json:"mutates_db"`
}
type DisplaySource struct {
Source string `json:"source"`
Table string `json:"table"`
Available bool `json:"available"`
RequiredColumns []string `json:"required_columns"`
PresentColumns []string `json:"present_columns"`
}
type MessageSource struct {
Source string `json:"source"`
Table string `json:"table"`
Role string `json:"role"`
Available bool `json:"available"`
RequiredColumns []string `json:"required_columns"`
PresentColumns []string `json:"present_columns"`
RowCount string `json:"row_count"`
}
type ListMessagesOptions struct {
ConversationID string
ConversationType string
Query string
Since string
Cursor string
Limit int
IncludeBody bool
IncludeAttachmentMetadata bool
}
type ListMessagesResult struct {
ReadOnly bool `json:"read_only"`
MessageBodyValuesReturned bool `json:"message_body_values_returned"`
RawRowsReturned bool `json:"raw_rows_returned"`
FilePathsReturned bool `json:"file_paths_returned"`
SourceTables []string `json:"source_tables"`
Messages []ListMessage `json:"messages"`
NextCursor string `json:"next_cursor"`
}
type ListMessage struct {
MessageID string `json:"message_id"`
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
ConversationType string `json:"conversation_type"`
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name"`
ReceiverID string `json:"receiver_id"`
Text string `json:"text"`
ContentText string `json:"content_text"`
ContentType string `json:"content_type"`
Timestamp string `json:"timestamp"`
CreatedAt string `json:"created_at"`
Subject string `json:"subject"`
Read bool `json:"read"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
Attachments []ListMessageAttachment `json:"attachments"`
}
type ListMessageAttachment struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
SizeBytes int64 `json:"size_bytes"`
DownloadRef string `json:"download_ref"`
}
type DisplayEntitiesOptions struct {
EntityType string
Query string
Limit int
}
type DisplayEntity struct {
EntityType string `json:"entity_type"`
SourceTable string `json:"source_table"`
JID string `json:"jid"`
DisplayName string `json:"display_name"`
Confidence float64 `json:"confidence"`
MatchedColumns []string `json:"matched_columns"`
}
type SidecarError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *SidecarError) Error() string {
if e == nil {
return "msglib sidecar error"
}
if e.Code == "" {
return "msglib sidecar error: " + e.Message
}
if e.Message == "" {
return "msglib sidecar error: " + e.Code
}
return fmt.Sprintf("msglib sidecar error %s: %s", e.Code, e.Message)
}
func (c *Client) SelfCheck(ctx context.Context) (SelfCheckResult, error) {
var out SelfCheckResult
err := c.call(ctx, "self_check", map[string]any{}, &out)
return out, err
}
func (c *Client) DisplaySources(ctx context.Context) ([]DisplaySource, error) {
args := map[string]any{
"sqlite_dll_path": c.config.SQLiteDLLPath,
"db_path": c.config.DBPath,
"password": c.config.Password,
}
var out displaySourcesData
if err := c.call(ctx, "display_sources", args, &out); err != nil {
return nil, err
}
return out.DisplaySources, nil
}
func (c *Client) MessageSources(ctx context.Context) ([]MessageSource, error) {
args := map[string]any{
"sqlite_dll_path": c.config.SQLiteDLLPath,
"db_path": c.config.DBPath,
"password": c.config.Password,
}
var out messageSourcesData
if err := c.call(ctx, "message_sources", args, &out); err != nil {
return nil, err
}
return out.MessageSources, nil
}
func (c *Client) ListMessages(ctx context.Context, opts ListMessagesOptions) (ListMessagesResult, error) {
if strings.TrimSpace(opts.Cursor) != "" {
return ListMessagesResult{}, errors.New("msglib list_messages cursor pagination is not available yet")
}
limit := opts.Limit
if limit <= 0 {
limit = DefaultListMessagesLimit
}
if limit > MaxListMessagesLimit {
limit = MaxListMessagesLimit
}
args := map[string]any{
"sqlite_dll_path": c.config.SQLiteDLLPath,
"db_path": c.config.DBPath,
"password": c.config.Password,
"limit": limit,
"include_body": opts.IncludeBody,
"include_attachment_metadata": opts.IncludeAttachmentMetadata,
}
if strings.TrimSpace(opts.ConversationID) != "" {
args["conversation_id"] = strings.TrimSpace(opts.ConversationID)
}
if strings.TrimSpace(opts.ConversationType) != "" {
args["conversation_type"] = strings.TrimSpace(opts.ConversationType)
}
if strings.TrimSpace(opts.Query) != "" {
args["query"] = strings.TrimSpace(opts.Query)
}
if strings.TrimSpace(opts.Since) != "" {
args["since"] = strings.TrimSpace(opts.Since)
}
var out ListMessagesResult
if err := c.call(ctx, "list_messages", args, &out); err != nil {
return ListMessagesResult{}, err
}
return out, nil
}
func (c *Client) DisplayEntities(ctx context.Context, opts DisplayEntitiesOptions) ([]DisplayEntity, error) {
entityType := strings.TrimSpace(opts.EntityType)
if entityType != EntityTypeContacts && entityType != EntityTypeGroups {
return nil, fmt.Errorf("entity_type must be %q or %q", EntityTypeContacts, EntityTypeGroups)
}
limit := opts.Limit
if limit <= 0 {
limit = DefaultDisplayEntityLimit
}
if limit > MaxDisplayEntityLimit {
limit = MaxDisplayEntityLimit
}
args := map[string]any{
"sqlite_dll_path": c.config.SQLiteDLLPath,
"db_path": c.config.DBPath,
"password": c.config.Password,
"entity_type": entityType,
"limit": limit,
}
if strings.TrimSpace(opts.Query) != "" {
args["query"] = strings.TrimSpace(opts.Query)
}
var out displayEntitiesData
if err := c.call(ctx, "display_entities", args, &out); err != nil {
return nil, err
}
return out.Entities, nil
}
type displaySourcesData struct {
DisplaySources []DisplaySource `json:"display_sources"`
}
type messageSourcesData struct {
MessageSources []MessageSource `json:"message_sources"`
}
type displayEntitiesData struct {
Entities []DisplayEntity `json:"entities"`
}
type requestEnvelope struct {
Protocol string `json:"protocol"`
RequestID string `json:"request_id"`
Op string `json:"op"`
Args map[string]any `json:"args,omitempty"`
}
type responseEnvelope struct {
Protocol string `json:"protocol"`
RequestID string `json:"request_id"`
Op string `json:"op"`
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *SidecarError `json:"error"`
Warnings []string `json:"warnings"`
}
func (c *Client) call(ctx context.Context, op string, args map[string]any, out any) error {
if ctx == nil {
ctx = context.Background()
}
if strings.TrimSpace(c.config.SidecarPath) == "" {
return errors.New("msglib sidecar path is not configured")
}
requestID := nextRequestID()
req := requestEnvelope{
Protocol: Protocol,
RequestID: requestID,
Op: op,
Args: args,
}
payload, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal msglib sidecar request: %w", err)
}
payload = append(payload, '\n')
callCtx := ctx
var cancel context.CancelFunc
if c.config.Timeout > 0 {
callCtx, cancel = context.WithTimeout(ctx, c.config.Timeout)
defer cancel()
}
cmd := exec.CommandContext(callCtx, c.config.SidecarPath, c.config.SidecarArgs...)
cmd.Stdin = bytes.NewReader(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if len(c.config.ExtraEnv) > 0 {
cmd.Env = append(os.Environ(), c.config.ExtraEnv...)
}
runErr := cmd.Run()
if callCtx.Err() != nil {
if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("msglib sidecar %s timed out: %w", op, context.DeadlineExceeded)
}
return fmt.Errorf("msglib sidecar %s context failed: %w", op, callCtx.Err())
}
if runErr != nil {
stderrText := strings.TrimSpace(stderr.String())
if stderrText == "" {
return fmt.Errorf("msglib sidecar %s process failed: %w", op, runErr)
}
return fmt.Errorf("msglib sidecar %s process failed: %w: %s", op, runErr, stderrText)
}
body := bytes.TrimSpace(stdout.Bytes())
if len(body) == 0 {
return fmt.Errorf("msglib sidecar %s returned empty stdout", op)
}
var resp responseEnvelope
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode msglib sidecar %s response: %w", op, err)
}
if resp.Protocol != Protocol {
return fmt.Errorf("msglib sidecar %s response protocol = %q, want %q", op, resp.Protocol, Protocol)
}
if resp.RequestID != requestID {
return fmt.Errorf("msglib sidecar %s response request_id = %q, want %q", op, resp.RequestID, requestID)
}
if resp.Op != op {
return fmt.Errorf("msglib sidecar response op = %q, want %q", resp.Op, op)
}
if !resp.OK {
if resp.Error != nil {
return fmt.Errorf("msglib sidecar %s failed: %w", op, resp.Error)
}
return fmt.Errorf("msglib sidecar %s failed", op)
}
if out == nil || len(resp.Data) == 0 || bytes.Equal(resp.Data, []byte("null")) {
return nil
}
if err := json.Unmarshal(resp.Data, out); err != nil {
return fmt.Errorf("decode msglib sidecar %s data: %w", op, err)
}
return nil
}
func nextRequestID() string {
n := atomic.AddUint64(&requestCounter, 1)
return fmt.Sprintf("go-%d-%d", time.Now().UnixNano(), n)
}

View File

@@ -0,0 +1,430 @@
package msglib
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
)
func TestSelfCheckSendsProtocolEnvelopeAndDecodesResponse(t *testing.T) {
client := newFakeClient(t, "self_check_ok", Config{})
got, err := client.SelfCheck(context.Background())
if err != nil {
t.Fatalf("SelfCheck returned error: %v", err)
}
if got.SidecarName != "MsgLibReadSidecar" {
t.Fatalf("SidecarName = %q, want MsgLibReadSidecar", got.SidecarName)
}
if got.Protocol != Protocol {
t.Fatalf("Protocol = %q, want %q", got.Protocol, Protocol)
}
if got.ProcessBits != 32 || !got.ReadOnly || got.MutatesDB {
t.Fatalf("unexpected safety fields: bits=%d read_only=%v mutates_db=%v", got.ProcessBits, got.ReadOnly, got.MutatesDB)
}
}
func TestDisplaySourcesPassesConfiguredPathsAndDecodesMetadata(t *testing.T) {
cfg := Config{
SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`,
DBPath: `C:\fixture\MsgLib.db`,
Password: "123",
}
client := newFakeClient(t, "display_sources_ok", cfg)
got, err := client.DisplaySources(context.Background())
if err != nil {
t.Fatalf("DisplaySources returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("len(DisplaySources) = %d, want 2", len(got))
}
if got[0].Source != "contacts_roster" || got[0].Table != "TD_Roster" || !got[0].Available {
t.Fatalf("unexpected first display source: %+v", got[0])
}
if strings.Join(got[0].RequiredColumns, ",") != "UserJID,Name" {
t.Fatalf("required columns = %#v", got[0].RequiredColumns)
}
if got[1].Source != "work_group_auth" || got[1].Available {
t.Fatalf("unexpected second display source: %+v", got[1])
}
}
func TestMessageSourcesPassesConfiguredPathsAndDecodesMetadata(t *testing.T) {
cfg := Config{
SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`,
DBPath: `C:\fixture\MsgLib.db`,
Password: "123",
}
client := newFakeClient(t, "message_sources_ok", cfg)
got, err := client.MessageSources(context.Background())
if err != nil {
t.Fatalf("MessageSources returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("len(MessageSources) = %d, want 2", len(got))
}
if got[0].Source != "direct_messages" || got[0].Table != "tblPersonMsg" || got[0].Role != "direct" || !got[0].Available {
t.Fatalf("unexpected first message source: %+v", got[0])
}
if got[0].RowCount != "2132" || strings.Join(got[0].RequiredColumns, ",") != "Id,SndPersonId,RecvPersonId,ObjPersonId,ActionTime,MsgText,MessageBody" {
t.Fatalf("unexpected first message source metadata: %+v", got[0])
}
if got[1].Source != "group_messages" || got[1].Table != "tblMsgGroupPersonMsg" || got[1].Role != "group" || got[1].RowCount != "2884" {
t.Fatalf("unexpected second message source: %+v", got[1])
}
}
func TestListMessagesPassesBoundedArgsAndDecodesNormalizedMessages(t *testing.T) {
cfg := Config{
SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`,
DBPath: `C:\fixture\MsgLib.db`,
Password: "123",
}
client := newFakeClient(t, "list_messages_ok", cfg)
got, err := client.ListMessages(context.Background(), ListMessagesOptions{
ConversationType: "direct",
Query: "redacted",
Since: "2026-07-10T00:00:00Z",
Limit: 99,
IncludeBody: false,
IncludeAttachmentMetadata: true,
})
if err != nil {
t.Fatalf("ListMessages returned error: %v", err)
}
if !got.ReadOnly || got.MessageBodyValuesReturned || got.RawRowsReturned || got.FilePathsReturned {
t.Fatalf("unexpected safety flags: %+v", got)
}
if strings.Join(got.SourceTables, ",") != "tblPersonMsg" {
t.Fatalf("SourceTables = %#v", got.SourceTables)
}
if len(got.Messages) != 1 {
t.Fatalf("len(Messages) = %d, want 1", len(got.Messages))
}
msg := got.Messages[0]
if msg.MessageID != "msg-redacted-1" || msg.ConversationType != "direct" || msg.RawRef != "msglib:tblPersonMsg" {
t.Fatalf("unexpected message identity: %+v", msg)
}
if msg.ContentText != "" || msg.Text != "" {
t.Fatalf("content should be omitted when include_body=false: %+v", msg)
}
if len(msg.Attachments) != 1 || msg.Attachments[0].FileName != "redacted.docx" || msg.Attachments[0].DownloadRef != "" {
t.Fatalf("unexpected attachments: %+v", msg.Attachments)
}
}
func TestDisplayEntitiesPassesBoundedArgsAndDecodesMetadata(t *testing.T) {
cfg := Config{
SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`,
DBPath: `C:\fixture\MsgLib.db`,
Password: "123",
}
client := newFakeClient(t, "display_entities_ok", cfg)
got, err := client.DisplayEntities(context.Background(), DisplayEntitiesOptions{EntityType: "contacts", Query: "alice", Limit: 2})
if err != nil {
t.Fatalf("DisplayEntities returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("len(DisplayEntities) = %d, want 2", len(got))
}
if got[0].EntityType != "contacts" || got[0].SourceTable != "TD_Roster" || got[0].JID != "alice@example" || got[0].DisplayName != "Alice" {
t.Fatalf("unexpected first entity: %+v", got[0])
}
if got[0].Confidence <= 0 || strings.Join(got[0].MatchedColumns, ",") != "JId,Nick" {
t.Fatalf("unexpected first entity metadata: %+v", got[0])
}
if got[1].SourceTable != "tblRecent" || got[1].JID != "recent@example" || got[1].DisplayName != "Recent Alice" {
t.Fatalf("unexpected second entity: %+v", got[1])
}
}
func TestDisplayEntitiesRejectsUnsupportedEntityType(t *testing.T) {
client := newFakeClient(t, "display_entities_ok", Config{})
_, err := client.DisplayEntities(context.Background(), DisplayEntitiesOptions{EntityType: "messages", Limit: 5})
if err == nil {
t.Fatal("DisplayEntities returned nil error, want unsupported entity_type error")
}
if !strings.Contains(err.Error(), "entity_type") {
t.Fatalf("DisplayEntities error = %v, want entity_type context", err)
}
}
func TestClientReturnsStructuredSidecarError(t *testing.T) {
client := newFakeClient(t, "sidecar_error", Config{})
_, err := client.SelfCheck(context.Background())
if err == nil {
t.Fatal("SelfCheck returned nil error, want sidecar error")
}
var sidecarErr *SidecarError
if !errors.As(err, &sidecarErr) {
t.Fatalf("error %T %[1]v does not wrap *SidecarError", err)
}
if sidecarErr.Code != "DB_OPEN_FAILED" || !strings.Contains(sidecarErr.Message, "read-only open failed") {
t.Fatalf("unexpected sidecar error: %+v", sidecarErr)
}
}
func TestClientTimeoutStopsSidecar(t *testing.T) {
client := newFakeClient(t, "hang", Config{Timeout: 150 * time.Millisecond})
_, err := client.SelfCheck(context.Background())
if err == nil {
t.Fatal("SelfCheck returned nil error, want timeout")
}
if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(strings.ToLower(err.Error()), "deadline") {
t.Fatalf("SelfCheck error = %v, want deadline exceeded", err)
}
}
func TestConfigFromEnvReadsSidecarShape(t *testing.T) {
t.Setenv("ISPHERE_MSGLIB_SIDECAR_EXE", `C:\tools\MsgLibReadSidecar.exe`)
t.Setenv("ISPHERE_MSGLIB_SQLITE_DLL", `C:\tools\System.Data.SQLite.dll`)
t.Setenv("ISPHERE_MSGLIB_DB", `C:\copy\MsgLib.db`)
cfg, err := ConfigFromEnv()
if err != nil {
t.Fatalf("ConfigFromEnv returned error: %v", err)
}
if cfg.SidecarPath != `C:\tools\MsgLibReadSidecar.exe` {
t.Fatalf("SidecarPath = %q", cfg.SidecarPath)
}
if cfg.SQLiteDLLPath != `C:\tools\System.Data.SQLite.dll` || cfg.DBPath != `C:\copy\MsgLib.db` {
t.Fatalf("unexpected DB config: %+v", cfg)
}
if cfg.Password != "123" {
t.Fatalf("Password = %q, want default 123", cfg.Password)
}
}
func newFakeClient(t *testing.T, mode string, overrides Config) *Client {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
cfg := overrides
cfg.SidecarPath = exe
cfg.SidecarArgs = []string{"-test.run=TestFakeSidecarProcess", "--"}
cfg.ExtraEnv = append(cfg.ExtraEnv, "ISPHERE_MSGLIB_FAKE_SIDECAR=1", "ISPHERE_MSGLIB_FAKE_MODE="+mode)
if cfg.Timeout == 0 {
cfg.Timeout = 2 * time.Second
}
return NewClient(cfg)
}
func TestFakeSidecarProcess(t *testing.T) {
if os.Getenv("ISPHERE_MSGLIB_FAKE_SIDECAR") != "1" {
return
}
os.Exit(fakeSidecarMain())
}
func fakeSidecarMain() int {
mode := os.Getenv("ISPHERE_MSGLIB_FAKE_MODE")
if mode == "hang" {
time.Sleep(5 * time.Second)
return 0
}
var req map[string]any
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
fmt.Fprintf(os.Stderr, "decode request: %v\n", err)
return 2
}
protocol, _ := req["protocol"].(string)
op, _ := req["op"].(string)
requestID, _ := req["request_id"].(string)
if protocol != Protocol || op == "" || requestID == "" {
fmt.Fprintf(os.Stderr, "bad envelope: protocol=%q op=%q request_id=%q\n", protocol, op, requestID)
return 2
}
if mode == "sidecar_error" {
return writeFakeResponse(map[string]any{
"protocol": Protocol,
"request_id": requestID,
"op": op,
"ok": false,
"data": nil,
"error": map[string]any{
"code": "DB_OPEN_FAILED",
"message": "read-only open failed",
},
"warnings": []any{},
})
}
switch op {
case "self_check":
return writeFakeResponse(map[string]any{
"protocol": Protocol,
"request_id": requestID,
"op": op,
"ok": true,
"data": map[string]any{
"sidecar_name": "MsgLibReadSidecar",
"sidecar_version": "0.1.0",
"protocol": Protocol,
"process_bits": 32,
"read_only": true,
"mutates_db": false,
},
"error": nil,
"warnings": []any{},
})
case "display_sources":
args, _ := req["args"].(map[string]any)
if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" {
fmt.Fprintf(os.Stderr, "bad display_sources args: %#v\n", args)
return 2
}
return writeFakeResponse(map[string]any{
"protocol": Protocol,
"request_id": requestID,
"op": op,
"ok": true,
"data": map[string]any{
"metadata_only": true,
"read_only": true,
"message_body_values_returned": false,
"display_sources": []any{
map[string]any{"source": "contacts_roster", "table": "TD_Roster", "available": true, "required_columns": []any{"UserJID", "Name"}, "present_columns": []any{"UserJID", "Name", "Mobile"}},
map[string]any{"source": "work_group_auth", "table": "TD_WorkGroupAuth", "available": false, "required_columns": []any{"GroupJID", "GroupName"}, "present_columns": []any{"GroupJID"}},
},
},
"error": nil,
"warnings": []any{},
})
case "message_sources":
args, _ := req["args"].(map[string]any)
if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" {
fmt.Fprintf(os.Stderr, "bad message_sources args: %#v\n", args)
return 2
}
return writeFakeResponse(map[string]any{
"protocol": Protocol,
"request_id": requestID,
"op": op,
"ok": true,
"data": map[string]any{
"metadata_only": true,
"read_only": true,
"message_body_values_returned": false,
"raw_rows_returned": false,
"file_paths_returned": false,
"message_sources": []any{
map[string]any{
"source": "direct_messages",
"table": "tblPersonMsg",
"role": "direct",
"available": true,
"required_columns": []any{"Id", "SndPersonId", "RecvPersonId", "ObjPersonId", "ActionTime", "MsgText", "MessageBody"},
"present_columns": []any{"Id", "SndPersonId", "RecvPersonId", "ObjPersonId", "ActionTime", "MsgText", "MessageBody"},
"row_count": "2132",
},
map[string]any{
"source": "group_messages",
"table": "tblMsgGroupPersonMsg",
"role": "group",
"available": true,
"required_columns": []any{"Id", "SndPersonId", "MsgGroupId", "MsgGroupName", "MsgTime", "MsgText", "MessageBody"},
"present_columns": []any{"Id", "SndPersonId", "MsgGroupId", "MsgGroupName", "MsgTime", "MsgText", "MessageBody"},
"row_count": "2884",
},
},
},
"error": nil,
"warnings": []any{},
})
case "list_messages":
args, _ := req["args"].(map[string]any)
if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" || args["conversation_type"] != "direct" || args["query"] != "redacted" || args["since"] != "2026-07-10T00:00:00Z" || args["limit"] != float64(50) || args["include_body"] != false || args["include_attachment_metadata"] != true {
fmt.Fprintf(os.Stderr, "bad list_messages args: %#v\n", args)
return 2
}
return writeFakeResponse(map[string]any{
"protocol": Protocol,
"request_id": requestID,
"op": op,
"ok": true,
"data": map[string]any{
"read_only": true,
"message_body_values_returned": false,
"raw_rows_returned": false,
"file_paths_returned": false,
"source_tables": []any{"tblPersonMsg"},
"messages": []any{
map[string]any{
"message_id": "msg-redacted-1",
"id": "msg-redacted-1",
"conversation_id": "contact-redacted",
"conversation_type": "direct",
"sender_id": "sender-redacted",
"sender_name": "Sender Redacted",
"receiver_id": "receiver-redacted",
"text": "",
"content_text": "",
"content_type": "file",
"timestamp": "2026-07-10T00:01:02Z",
"created_at": "2026-07-10T00:01:02Z",
"read": true,
"source": "local_readonly",
"raw_ref": "msglib:tblPersonMsg",
"attachments": []any{
map[string]any{"file_id": "msg-redacted-1:redacted.docx", "file_name": "redacted.docx", "size_bytes": float64(1234), "download_ref": ""},
},
},
},
},
"error": nil,
"warnings": []any{},
})
case "display_entities":
args, _ := req["args"].(map[string]any)
if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" || args["entity_type"] != "contacts" || args["query"] != "alice" || args["limit"] != float64(2) {
fmt.Fprintf(os.Stderr, "bad display_entities args: %#v\n", args)
return 2
}
return writeFakeResponse(map[string]any{
"protocol": Protocol,
"request_id": requestID,
"op": op,
"ok": true,
"data": map[string]any{
"metadata_only": true,
"read_only": true,
"entity_type": "contacts",
"limit": 2,
"result_count": 2,
"entities": []any{
map[string]any{"entity_type": "contacts", "source_table": "TD_Roster", "jid": "alice@example", "display_name": "Alice", "confidence": 0.95, "matched_columns": []any{"JId", "Nick"}},
map[string]any{"entity_type": "contacts", "source_table": "tblRecent", "jid": "recent@example", "display_name": "Recent Alice", "confidence": 0.70, "matched_columns": []any{"PersonId", "PersonName"}},
},
},
"error": nil,
"warnings": []any{},
})
default:
fmt.Fprintf(os.Stderr, "unexpected op: %s\n", op)
return 2
}
}
func writeFakeResponse(resp map[string]any) int {
if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil {
fmt.Fprintf(os.Stderr, "encode response: %v\n", err)
return 2
}
return 0
}

View File

@@ -0,0 +1,188 @@
package tools
import (
"context"
"sort"
"strings"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
type DisplayEntitySource interface {
DisplayEntities(ctx context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error)
}
func displayEntityQueryLimit(limit int) int {
if limit <= 0 {
return msglib.DefaultDisplayEntityLimit
}
if limit > msglib.MaxDisplayEntityLimit {
return msglib.MaxDisplayEntityLimit
}
return limit
}
func contactsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Contact {
contacts := make([]isphere.Contact, 0, len(entities))
for _, entity := range entities {
id := strings.TrimSpace(entity.JID)
name := strings.TrimSpace(entity.DisplayName)
if id == "" {
id = name
}
if name == "" {
name = id
}
if id == "" {
continue
}
contacts = append(contacts, isphere.Contact{
ContactID: id,
DisplayName: name,
Account: strings.TrimSpace(entity.JID),
Source: "msglib_readonly",
Confidence: entity.Confidence,
RawRef: "msglib:" + entity.SourceTable,
})
}
return contacts
}
func groupsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Group {
groups := make([]isphere.Group, 0, len(entities))
for _, entity := range entities {
id := strings.TrimSpace(entity.JID)
name := strings.TrimSpace(entity.DisplayName)
if id == "" {
id = name
}
if name == "" {
name = id
}
if id == "" {
continue
}
groups = append(groups, isphere.Group{
GroupID: id,
DisplayName: name,
Source: "msglib_readonly",
Confidence: entity.Confidence,
RawRef: "msglib:" + entity.SourceTable,
})
}
return groups
}
func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, query string, limit int) isphere.SearchContactsResult {
seen := map[string]bool{}
contacts := make([]isphere.Contact, 0, len(primary)+len(fallback))
for _, contact := range append(primary, fallback...) {
id := strings.TrimSpace(contact.ContactID)
if id == "" || seen[strings.ToLower(id)] {
continue
}
seen[strings.ToLower(id)] = true
contacts = append(contacts, contact)
}
sortContactsForQuery(contacts, query)
if limit > 0 && len(contacts) > limit {
contacts = contacts[:limit]
}
return isphere.SearchContactsResult{Contacts: contacts}
}
func mergeGroups(primary []isphere.Group, fallback []isphere.Group, query string, limit int) isphere.SearchGroupsResult {
seen := map[string]bool{}
groups := make([]isphere.Group, 0, len(primary)+len(fallback))
for _, group := range append(primary, fallback...) {
id := strings.TrimSpace(group.GroupID)
if id == "" || seen[strings.ToLower(id)] {
continue
}
seen[strings.ToLower(id)] = true
groups = append(groups, group)
}
sortGroupsForQuery(groups, query)
if limit > 0 && len(groups) > limit {
groups = groups[:limit]
}
return isphere.SearchGroupsResult{Groups: groups}
}
func sortContactsForQuery(contacts []isphere.Contact, query string) {
queryText := strings.ToLower(strings.TrimSpace(query))
sort.Slice(contacts, func(i, j int) bool {
leftRank := contactRankForQuery(contacts[i], queryText)
rightRank := contactRankForQuery(contacts[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(contacts[i].ContactID)
rightID := strings.ToLower(contacts[j].ContactID)
if leftID != rightID {
return leftID < rightID
}
return contacts[i].ContactID < contacts[j].ContactID
})
}
func contactRankForQuery(contact isphere.Contact, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(contact.ContactID),
strings.ToLower(contact.DisplayName),
strings.ToLower(contact.Account),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}
func sortGroupsForQuery(groups []isphere.Group, query string) {
queryText := strings.ToLower(strings.TrimSpace(query))
sort.Slice(groups, func(i, j int) bool {
leftRank := groupRankForQuery(groups[i], queryText)
rightRank := groupRankForQuery(groups[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(groups[i].GroupID)
rightID := strings.ToLower(groups[j].GroupID)
if leftID != rightID {
return leftID < rightID
}
return groups[i].GroupID < groups[j].GroupID
})
}
func groupRankForQuery(group isphere.Group, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(group.GroupID),
strings.ToLower(group.DisplayName),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -0,0 +1,88 @@
package tools
import (
"context"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
const ToolNameSearchContacts = "isphere_search_contacts"
type SearchContactsArgs struct {
Query string `json:"query" jsonschema:"contact search query; currently matches bare JIDs from local readonly message logs"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of contacts to return; zero or negative returns all matches"`
Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"`
SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"`
IncludeInactive bool `json:"include_inactive,omitempty" jsonschema:"accepted for contract compatibility; local readonly message logs do not expose inactive state"`
}
func RegisterISphereContactTools(server *mcp.Server, source ReceiveMessagesSource) {
RegisterISphereContactToolsWithDisplayEntities(server, source, nil)
}
func RegisterISphereContactToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) {
if source == nil {
source = isphere.EncryptedPacketLogSource{}
}
mcp.AddTool[SearchContactsArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameSearchContacts,
Description: "Search iSphere contact candidates from the configured read-only message source.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SearchContactsArgs) (*mcp.CallToolResult, map[string]any, error) {
if err := validateLocalReadonlySourceAndCursor(ToolNameSearchContacts, input.SourcePreference, input.Cursor); err != nil {
return nil, nil, err
}
started := time.Now().UTC()
messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0})
if err != nil {
return nil, nil, err
}
logContacts := isphere.SearchContactsFromMessages(messages.Messages, isphere.SearchContactsQuery{Query: input.Query, Limit: input.Limit})
contacts := logContacts
if displaySource != nil {
entities, err := displaySource.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{
EntityType: msglib.EntityTypeContacts,
Query: input.Query,
Limit: displayEntityQueryLimit(input.Limit),
})
if err != nil {
return nil, nil, err
}
contacts = mergeContacts(contactsFromDisplayEntities(entities), logContacts.Contacts, input.Query, input.Limit)
}
return nil, searchContactsResultToMap(contacts, started, time.Now().UTC()), nil
})
}
func searchContactsResultToMap(result isphere.SearchContactsResult, started time.Time, finished time.Time) map[string]any {
contacts := make([]map[string]any, 0, len(result.Contacts))
for _, contact := range result.Contacts {
contacts = append(contacts, map[string]any{
"contact_id": contact.ContactID,
"display_name": contact.DisplayName,
"account": contact.Account,
"department": nil,
"title": nil,
"source": contact.Source,
"confidence": contact.Confidence,
"raw_ref": contact.RawRef,
})
}
return map[string]any{
"ok": true,
"contacts": contacts,
"next_cursor": nil,
"audit": map[string]any{
"tool": ToolNameSearchContacts,
"source": "local_readonly",
"execution_mode": "read",
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": "ok",
},
}
}

View File

@@ -0,0 +1,239 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
func TestISphereSearchContactsToolReturnsJIDContacts(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
SenderID: "alice@imopenfire1-lanzhou",
ReceiverID: "bob@imopenfire1-lanzhou",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereContactTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "alice", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchContacts, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
Contacts []struct {
ContactID string `json:"contact_id"`
DisplayName string `json:"display_name"`
Account string `json:"account"`
Source string `json:"source"`
Confidence float64 `json:"confidence"`
RawRef string `json:"raw_ref"`
} `json:"contacts"`
NextCursor any `json:"next_cursor"`
Audit map[string]any `json:"audit"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if !decoded.OK {
t.Fatalf("ok = false in %s", payload)
}
if len(decoded.Contacts) != 1 {
t.Fatalf("contacts = %+v, want one", decoded.Contacts)
}
contact := decoded.Contacts[0]
if contact.ContactID != "alice@imopenfire1-lanzhou" || contact.DisplayName != "alice@imopenfire1-lanzhou" || contact.Account != "alice@imopenfire1-lanzhou" {
t.Fatalf("unexpected contact identity: %+v", contact)
}
if contact.Source != "local_readonly" || contact.Confidence != 0.6 || contact.RawRef != "message_jid" {
t.Fatalf("unexpected contact metadata: %+v", contact)
}
if decoded.Audit["tool"] != ToolNameSearchContacts || decoded.Audit["result"] != "ok" {
t.Fatalf("audit = %+v", decoded.Audit)
}
if len(fake.queries) != 1 || fake.queries[0].Limit != 0 {
t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries)
}
}
func TestISphereSearchContactsToolUsesInjectedDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{{
EntityType: msglib.EntityTypeContacts,
SourceTable: "TD_CustomEffigy",
JID: "alice@example",
DisplayName: "Alice Zhang",
Confidence: 0.9,
MatchedColumns: []string{"PersonJid", "PersonName"},
}}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "alice", "limit": 5},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchContacts, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Contacts []struct {
ContactID string `json:"contact_id"`
DisplayName string `json:"display_name"`
Account string `json:"account"`
Source string `json:"source"`
Confidence float64 `json:"confidence"`
RawRef string `json:"raw_ref"`
} `json:"contacts"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Contacts) != 1 {
t.Fatalf("contacts = %+v, want one", decoded.Contacts)
}
contact := decoded.Contacts[0]
if contact.ContactID != "alice@example" || contact.DisplayName != "Alice Zhang" || contact.Account != "alice@example" {
t.Fatalf("unexpected MsgLib contact identity: %+v", contact)
}
if contact.Source != "msglib_readonly" || contact.Confidence != 0.9 || contact.RawRef != "msglib:TD_CustomEffigy" {
t.Fatalf("unexpected MsgLib contact metadata: %+v", contact)
}
if len(fakeDisplay.queries) != 1 || fakeDisplay.queries[0].EntityType != msglib.EntityTypeContacts || fakeDisplay.queries[0].Query != "alice" || fakeDisplay.queries[0].Limit != 5 {
t.Fatalf("display queries = %+v", fakeDisplay.queries)
}
}
func TestISphereSearchContactsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
SenderID: "target@imopenfire1-lanzhou",
ReceiverID: "other@imopenfire1-lanzhou",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "zzz-target@imopenfire1-lanzhou", DisplayName: "ZZZ Target", Confidence: 0.8},
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "target@imopenfire1-lanzhou", DisplayName: "Target", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "target@imopenfire1-lanzhou", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchContacts, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Contacts []struct {
ContactID string `json:"contact_id"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"contacts"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Contacts) != 2 {
t.Fatalf("contacts = %+v, want exact and substring only", decoded.Contacts)
}
if decoded.Contacts[0].ContactID != "target@imopenfire1-lanzhou" || decoded.Contacts[0].Source != "msglib_readonly" || decoded.Contacts[0].RawRef != "msglib:TD_CustomEffigy" {
t.Fatalf("first contact should be exact MsgLib match with source metadata, got %+v", decoded.Contacts[0])
}
if decoded.Contacts[1].ContactID != "zzz-target@imopenfire1-lanzhou" {
t.Fatalf("second contact should be substring match, got %+v", decoded.Contacts[1])
}
}
func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
SenderID: "alice@imopenfire1-lanzhou",
ReceiverID: "bob@imopenfire1-lanzhou",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereContactTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{
"query": "alice",
"source_preference": "local_readonly",
"include_inactive": true,
"cursor": "",
"limit": 10,
},
})
if err != nil {
t.Fatalf("call with safe contract args: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "alice", "source_preference": "bridge"},
})
if err == nil && !bridgeResult.IsError {
t.Fatalf("bridge source_preference was accepted before connector exists")
}
cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "alice", "cursor": "next-page"},
})
if err == nil && !cursorResult.IsError {
t.Fatalf("non-empty cursor was accepted before pagination exists")
}
}
type fakeDisplayEntitySource struct {
queries []msglib.DisplayEntitiesOptions
entities []msglib.DisplayEntity
}
func (f *fakeDisplayEntitySource) DisplayEntities(_ context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error) {
f.queries = append(f.queries, opts)
return f.entities, nil
}

View File

@@ -0,0 +1,216 @@
package tools
import (
"context"
"fmt"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
)
const ToolNameReceiveFiles = "isphere_receive_files"
type ReceiveFileDownloadQuery struct {
FileRef string
OutputDir string
Preview bool
}
type ReceiveFileDownloadResolution struct {
Found bool
Source string
MatchedScore int
BlockedReasonCode string
BlockedReasonMessage string
}
type ReceiveFileDownloadResolver interface {
ResolveReceiveFileDownload(context.Context, ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error)
}
type receiveFileDownloadResolverFunc func(context.Context, ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error)
func (f receiveFileDownloadResolverFunc) ResolveReceiveFileDownload(ctx context.Context, query ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) {
return f(ctx, query)
}
type ReceiveFilesArgs struct {
ConversationID string `json:"conversation_id,omitempty" jsonschema:"conversation id filter for file listing"`
MessageID string `json:"message_id,omitempty" jsonschema:"message id filter for file listing"`
FileID string `json:"file_id,omitempty" jsonschema:"file id filter for file listing"`
FileRef string `json:"file_ref,omitempty" jsonschema:"download file reference; defaults to file_id when omitted"`
NameContains string `json:"name_contains,omitempty" jsonschema:"case-insensitive filename substring filter"`
Mode string `json:"mode,omitempty" jsonschema:"file receive mode; C14 supports list only"`
OutputDir string `json:"output_dir,omitempty" jsonschema:"download output directory; not accepted in current list-only mode"`
Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"`
SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"`
Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility in list mode"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of files to return; zero or negative returns all matches"`
}
func RegisterISphereFileTools(server *mcp.Server, source ReceiveMessagesSource) {
RegisterISphereFileToolsWithDownloadResolver(server, source, nil)
}
func RegisterISphereFileToolsWithDownloadResolver(server *mcp.Server, source ReceiveMessagesSource, downloadResolver ReceiveFileDownloadResolver) {
if source == nil {
source = isphere.EncryptedPacketLogSource{}
}
mcp.AddTool[ReceiveFilesArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameReceiveFiles,
Description: "List iSphere file metadata candidates from the configured read-only message source; download preview returns structured blocked/planned status until cache mapping is validated.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveFilesArgs) (*mcp.CallToolResult, map[string]any, error) {
started := time.Now().UTC()
mode := strings.ToLower(strings.TrimSpace(input.Mode))
if mode == "" {
mode = "list"
}
if mode != "list" && mode != "download" {
return nil, nil, fmt.Errorf("isphere_receive_files mode %q is not available; supported modes are list and download preview", input.Mode)
}
if err := validateLocalReadonlySourceAndCursor(ToolNameReceiveFiles, input.SourcePreference, input.Cursor); err != nil {
return nil, nil, err
}
if mode == "download" {
return nil, receiveFileDownloadPreviewResult(ctx, input, downloadResolver, started), nil
}
if strings.TrimSpace(input.OutputDir) != "" {
return nil, nil, fmt.Errorf("isphere_receive_files output_dir is only available for download mode")
}
messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0})
if err != nil {
return nil, nil, err
}
files := isphere.ListFilesFromMessages(messages.Messages, isphere.ListFilesQuery{
ConversationID: input.ConversationID,
MessageID: input.MessageID,
FileID: input.FileID,
NameContains: input.NameContains,
Limit: input.Limit,
})
return nil, receiveFilesResultToMap(files, started, time.Now().UTC()), nil
})
}
func receiveFileDownloadPreviewResult(ctx context.Context, input ReceiveFilesArgs, resolver ReceiveFileDownloadResolver, started time.Time) map[string]any {
fileRef := strings.TrimSpace(input.FileRef)
if fileRef == "" {
fileRef = strings.TrimSpace(input.FileID)
}
if fileRef == "" {
return receiveFileDownloadBlockedResult(fileRef, "file_ref_required", "download preview requires file_ref or file_id", started, time.Now().UTC())
}
if !input.Preview {
return receiveFileDownloadBlockedResult(fileRef, "file_download_production_disabled", "real file download is blocked; pass preview=true for a no-copy plan", started, time.Now().UTC())
}
if resolver == nil {
return receiveFileDownloadBlockedResult(fileRef, "file_cache_mapping_missing", "no accepted receive-file cache mapping is configured", started, time.Now().UTC())
}
resolution, err := resolver.ResolveReceiveFileDownload(ctx, ReceiveFileDownloadQuery{
FileRef: fileRef,
OutputDir: strings.TrimSpace(input.OutputDir),
Preview: true,
})
if err != nil {
return receiveFileDownloadBlockedResult(fileRef, "file_cache_mapping_error", err.Error(), started, time.Now().UTC())
}
if !resolution.Found {
code := strings.TrimSpace(resolution.BlockedReasonCode)
if code == "" {
code = "file_cache_mapping_missing"
}
message := strings.TrimSpace(resolution.BlockedReasonMessage)
if message == "" {
message = "no accepted receive-file cache mapping matched the requested file_ref"
}
return receiveFileDownloadBlockedResult(fileRef, code, message, started, time.Now().UTC())
}
return map[string]any{
"ok": true,
"mode": "download",
"download_status": "planned",
"file_ref": fileRef,
"output_dir_requested": strings.TrimSpace(input.OutputDir) != "",
"mapping_source": resolution.Source,
"matched_score": resolution.MatchedScore,
"preview": true,
"file_contents_read": false,
"file_copied": false,
"real_download_attempted": false,
"raw_paths_returned": false,
"audit": receiveFileDownloadAudit("planned", started, time.Now().UTC()),
}
}
func receiveFileDownloadBlockedResult(fileRef string, reasonCode string, reasonMessage string, started time.Time, finished time.Time) map[string]any {
return map[string]any{
"ok": false,
"mode": "download",
"download_status": "blocked",
"blocked_reason_code": reasonCode,
"blocked_reason_message": reasonMessage,
"file_ref": fileRef,
"preview": true,
"file_contents_read": false,
"file_copied": false,
"real_download_attempted": false,
"raw_paths_returned": false,
"audit": receiveFileDownloadAudit("blocked", started, finished),
}
}
func receiveFileDownloadAudit(result string, started time.Time, finished time.Time) map[string]any {
return map[string]any{
"tool": ToolNameReceiveFiles,
"source": "local_readonly",
"execution_mode": "download_preview",
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": result,
}
}
func receiveFilesResultToMap(result isphere.ListFilesResult, started time.Time, finished time.Time) map[string]any {
files := make([]map[string]any, 0, len(result.Files))
for _, file := range result.Files {
files = append(files, map[string]any{
"file_id": file.FileID,
"message_id": nullableString(file.MessageID),
"conversation_id": nullableString(file.ConversationID),
"file_name": file.FileName,
"size_bytes": file.SizeBytes,
"mime_type": file.MimeType,
"created_at": nullableString(file.CreatedAt),
"download_ref": nullableString(file.DownloadRef),
"saved_path": nullableString(file.SavedPath),
"sha256": nullableString(file.SHA256),
"source": file.Source,
"raw_ref": file.RawRef,
})
}
return map[string]any{
"ok": true,
"mode": "list",
"files": files,
"next_cursor": nil,
"audit": map[string]any{
"tool": ToolNameReceiveFiles,
"source": "local_readonly",
"execution_mode": "read",
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": "ok",
},
}
}
func nullableString(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}

View File

@@ -0,0 +1,286 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
)
func TestISphereReceiveFilesToolReturnsLogBackedFiles(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-file-1",
ConversationID: "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou",
Text: "redacted-report.docx",
Timestamp: "1783423807000",
Subject: "FILE_TRANSFER_CANCEL",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereFileTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "name_contains": "report", "limit": 5},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameReceiveFiles, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
Mode string `json:"mode"`
Files []struct {
FileID string `json:"file_id"`
MessageID string `json:"message_id"`
ConversationID string `json:"conversation_id"`
FileName string `json:"file_name"`
SizeBytes *int64 `json:"size_bytes"`
MimeType string `json:"mime_type"`
CreatedAt string `json:"created_at"`
DownloadRef *string `json:"download_ref"`
SavedPath *string `json:"saved_path"`
SHA256 *string `json:"sha256"`
Source string `json:"source"`
} `json:"files"`
NextCursor any `json:"next_cursor"`
Audit map[string]any `json:"audit"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if !decoded.OK || decoded.Mode != "list" {
t.Fatalf("ok/mode = %v/%q in %s", decoded.OK, decoded.Mode, payload)
}
if len(decoded.Files) != 1 {
t.Fatalf("files = %+v, want one", decoded.Files)
}
file := decoded.Files[0]
if file.FileID != "msg-file-1:redacted-report.docx" || file.MessageID != "msg-file-1" || file.ConversationID != "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou" {
t.Fatalf("unexpected file identity: %+v", file)
}
if file.FileName != "redacted-report.docx" || file.MimeType != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || file.CreatedAt != "1783423807000" {
t.Fatalf("unexpected file metadata: %+v", file)
}
if file.SizeBytes != nil || file.DownloadRef != nil || file.SavedPath != nil || file.SHA256 != nil {
t.Fatalf("download/cache fields should be unknown: %+v", file)
}
if file.Source != "local_readonly" {
t.Fatalf("source = %q", file.Source)
}
if decoded.Audit["tool"] != ToolNameReceiveFiles || decoded.Audit["result"] != "ok" || decoded.Audit["execution_mode"] != "read" {
t.Fatalf("audit = %+v", decoded.Audit)
}
if len(fake.queries) != 1 || fake.queries[0].Limit != 0 {
t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries)
}
}
func TestISphereReceiveFilesToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-file-1",
ConversationID: "alice@imopenfire1-lanzhou|bob@imopenfire1-lanzhou",
Text: "redacted-report.docx",
Timestamp: "1783423807000",
Subject: "FILE_TRANSFER_CANCEL",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereFileTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{
"mode": "list",
"name_contains": "report",
"source_preference": "local_readonly",
"preview": true,
"cursor": "",
"output_dir": "",
"limit": 5,
},
})
if err != nil {
t.Fatalf("call with safe contract args: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "source_preference": "bridge"},
})
if err == nil && !bridgeResult.IsError {
t.Fatalf("bridge source_preference was accepted before connector exists")
}
cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "cursor": "next-page"},
})
if err == nil && !cursorResult.IsError {
t.Fatalf("non-empty cursor was accepted before pagination exists")
}
outputResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{"mode": "list", "output_dir": "C:\\tmp\\isphere"},
})
if err == nil && !outputResult.IsError {
t.Fatalf("output_dir was accepted in list mode before download exists")
}
downloadResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{
"mode": "download",
"preview": true,
"file_id": "msg-file-1:redacted-report.docx",
},
})
if err != nil {
t.Fatalf("download preview returned transport error: %v", err)
}
if downloadResult.IsError {
t.Fatalf("download preview should return structured blocked content: %+v", downloadResult.Content)
}
var downloadPayload map[string]any
downloadEncoded, _ := json.Marshal(downloadResult.StructuredContent)
if err := json.Unmarshal(downloadEncoded, &downloadPayload); err != nil {
t.Fatalf("decode download structured content %s: %v", downloadEncoded, err)
}
if downloadPayload["ok"] != false || downloadPayload["download_status"] != "blocked" || downloadPayload["blocked_reason_code"] != "file_cache_mapping_missing" {
t.Fatalf("unexpected download preview block: %s", downloadEncoded)
}
}
func TestISphereReceiveFilesDownloadPreviewBlockedWithoutMapping(t *testing.T) {
fake := &fakeReceiveMessagesSource{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereFileTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{
"mode": "download",
"preview": true,
"file_ref": "msg-file-1:redacted-report.docx",
"output_dir": "C:\\tmp\\isphere-downloads",
},
})
if err != nil {
t.Fatalf("download preview call returned transport error: %v", err)
}
if callResult.IsError {
t.Fatalf("download preview should return structured blocked content, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
Mode string `json:"mode"`
DownloadStatus string `json:"download_status"`
BlockedReasonCode string `json:"blocked_reason_code"`
FileContentsRead bool `json:"file_contents_read"`
FileCopied bool `json:"file_copied"`
RealDownload bool `json:"real_download_attempted"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if decoded.OK || decoded.Mode != "download" || decoded.DownloadStatus != "blocked" {
t.Fatalf("unexpected blocked response: %+v payload=%s", decoded, payload)
}
if decoded.BlockedReasonCode != "file_cache_mapping_missing" {
t.Fatalf("blocked reason = %q", decoded.BlockedReasonCode)
}
if decoded.FileContentsRead || decoded.FileCopied || decoded.RealDownload {
t.Fatalf("download side effects must be false: %+v", decoded)
}
}
func TestISphereReceiveFilesDownloadPreviewPlannedWithFixtureMapping(t *testing.T) {
resolver := receiveFileDownloadResolverFunc(func(ctx context.Context, query ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) {
if query.FileRef != "msg-file-1:redacted-report.docx" {
t.Fatalf("resolver file_ref = %q", query.FileRef)
}
if query.OutputDir != "C:\\tmp\\isphere-downloads" {
t.Fatalf("resolver output_dir = %q", query.OutputDir)
}
return ReceiveFileDownloadResolution{
Found: true,
Source: "fixture-cache-mapping",
MatchedScore: 100,
}, nil
})
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereFileToolsWithDownloadResolver(server, nil, resolver)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveFiles,
Arguments: map[string]any{
"mode": "download",
"preview": true,
"file_ref": "msg-file-1:redacted-report.docx",
"output_dir": "C:\\tmp\\isphere-downloads",
},
})
if err != nil {
t.Fatalf("download preview call returned transport error: %v", err)
}
if callResult.IsError {
t.Fatalf("download preview should return planned content, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
Mode string `json:"mode"`
DownloadStatus string `json:"download_status"`
FileRef string `json:"file_ref"`
MappingSource string `json:"mapping_source"`
MatchedScore int `json:"matched_score"`
FileContentsRead bool `json:"file_contents_read"`
FileCopied bool `json:"file_copied"`
RealDownload bool `json:"real_download_attempted"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if !decoded.OK || decoded.Mode != "download" || decoded.DownloadStatus != "planned" {
t.Fatalf("unexpected planned response: %+v payload=%s", decoded, payload)
}
if decoded.FileRef != "msg-file-1:redacted-report.docx" || decoded.MappingSource != "fixture-cache-mapping" || decoded.MatchedScore != 100 {
t.Fatalf("unexpected mapping metadata: %+v", decoded)
}
if decoded.FileContentsRead || decoded.FileCopied || decoded.RealDownload {
t.Fatalf("preview side effects must be false: %+v", decoded)
}
}

View File

@@ -0,0 +1,87 @@
package tools
import (
"context"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
const ToolNameSearchGroups = "isphere_search_groups"
type SearchGroupsArgs struct {
Query string `json:"query" jsonschema:"group search query; currently matches bare group JIDs from local readonly message logs"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of groups to return; zero or negative returns all matches"`
Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"`
SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"`
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"accepted for contract compatibility; local readonly message logs do not expose archived state"`
}
func RegisterISphereGroupTools(server *mcp.Server, source ReceiveMessagesSource) {
RegisterISphereGroupToolsWithDisplayEntities(server, source, nil)
}
func RegisterISphereGroupToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) {
if source == nil {
source = isphere.EncryptedPacketLogSource{}
}
mcp.AddTool[SearchGroupsArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameSearchGroups,
Description: "Search iSphere group candidates from the configured read-only message source.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SearchGroupsArgs) (*mcp.CallToolResult, map[string]any, error) {
if err := validateLocalReadonlySourceAndCursor(ToolNameSearchGroups, input.SourcePreference, input.Cursor); err != nil {
return nil, nil, err
}
started := time.Now().UTC()
messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0})
if err != nil {
return nil, nil, err
}
logGroups := isphere.SearchGroupsFromMessages(messages.Messages, isphere.SearchGroupsQuery{Query: input.Query, Limit: input.Limit})
groups := logGroups
if displaySource != nil {
entities, err := displaySource.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{
EntityType: msglib.EntityTypeGroups,
Query: input.Query,
Limit: displayEntityQueryLimit(input.Limit),
})
if err != nil {
return nil, nil, err
}
groups = mergeGroups(groupsFromDisplayEntities(entities), logGroups.Groups, input.Query, input.Limit)
}
return nil, searchGroupsResultToMap(groups, started, time.Now().UTC()), nil
})
}
func searchGroupsResultToMap(result isphere.SearchGroupsResult, started time.Time, finished time.Time) map[string]any {
groups := make([]map[string]any, 0, len(result.Groups))
for _, group := range result.Groups {
groups = append(groups, map[string]any{
"group_id": group.GroupID,
"display_name": group.DisplayName,
"member_count": nil,
"owner_ref": nil,
"source": group.Source,
"confidence": group.Confidence,
"raw_ref": group.RawRef,
})
}
return map[string]any{
"ok": true,
"groups": groups,
"next_cursor": nil,
"audit": map[string]any{
"tool": ToolNameSearchGroups,
"source": "local_readonly",
"execution_mode": "read",
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": "ok",
},
}
}

View File

@@ -0,0 +1,235 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
func TestISphereSearchGroupsToolReturnsJIDGroups(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-group-1",
ConversationType: "groupchat",
SenderID: "project-room@conference.imopenfire1-lanzhou",
ReceiverID: "user@imopenfire1-lanzhou",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereGroupTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "project", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchGroups, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
Groups []struct {
GroupID string `json:"group_id"`
DisplayName string `json:"display_name"`
MemberCount *int `json:"member_count"`
OwnerRef *string `json:"owner_ref"`
Source string `json:"source"`
Confidence float64 `json:"confidence"`
RawRef string `json:"raw_ref"`
} `json:"groups"`
NextCursor any `json:"next_cursor"`
Audit map[string]any `json:"audit"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if !decoded.OK {
t.Fatalf("ok = false in %s", payload)
}
if len(decoded.Groups) != 1 {
t.Fatalf("groups = %+v, want one", decoded.Groups)
}
group := decoded.Groups[0]
if group.GroupID != "project-room@conference.imopenfire1-lanzhou" || group.DisplayName != "project-room@conference.imopenfire1-lanzhou" {
t.Fatalf("unexpected group identity: %+v", group)
}
if group.MemberCount != nil || group.OwnerRef != nil {
t.Fatalf("member/owner should be unknown: %+v", group)
}
if group.Source != "local_readonly" || group.Confidence != 0.6 || group.RawRef != "groupchat_jid" {
t.Fatalf("unexpected group metadata: %+v", group)
}
if decoded.Audit["tool"] != ToolNameSearchGroups || decoded.Audit["result"] != "ok" {
t.Fatalf("audit = %+v", decoded.Audit)
}
if len(fake.queries) != 1 || fake.queries[0].Limit != 0 {
t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries)
}
}
func TestISphereSearchGroupsToolUsesInjectedDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{{
EntityType: msglib.EntityTypeGroups,
SourceTable: "TD_WorkGroupAuth",
JID: "project-room@conference",
DisplayName: "Project Room",
Confidence: 0.9,
MatchedColumns: []string{"GroupJID", "GroupName"},
}}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereGroupToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "project", "limit": 5},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchGroups, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Groups []struct {
GroupID string `json:"group_id"`
DisplayName string `json:"display_name"`
Source string `json:"source"`
Confidence float64 `json:"confidence"`
RawRef string `json:"raw_ref"`
} `json:"groups"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Groups) != 1 {
t.Fatalf("groups = %+v, want one", decoded.Groups)
}
group := decoded.Groups[0]
if group.GroupID != "project-room@conference" || group.DisplayName != "Project Room" {
t.Fatalf("unexpected MsgLib group identity: %+v", group)
}
if group.Source != "msglib_readonly" || group.Confidence != 0.9 || group.RawRef != "msglib:TD_WorkGroupAuth" {
t.Fatalf("unexpected MsgLib group metadata: %+v", group)
}
if len(fakeDisplay.queries) != 1 || fakeDisplay.queries[0].EntityType != msglib.EntityTypeGroups || fakeDisplay.queries[0].Query != "project" || fakeDisplay.queries[0].Limit != 5 {
t.Fatalf("display queries = %+v", fakeDisplay.queries)
}
}
func TestISphereSearchGroupsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
ConversationType: "groupchat",
SenderID: "target@conference.imopenfire1-lanzhou",
ReceiverID: "member@imopenfire1-lanzhou",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "zzz-target@conference.imopenfire1-lanzhou", DisplayName: "ZZZ Target Group", Confidence: 0.8},
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "target@conference.imopenfire1-lanzhou", DisplayName: "Target Group", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereGroupToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "target@conference.imopenfire1-lanzhou", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchGroups, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Groups []struct {
GroupID string `json:"group_id"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"groups"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Groups) != 2 {
t.Fatalf("groups = %+v, want exact and substring only", decoded.Groups)
}
if decoded.Groups[0].GroupID != "target@conference.imopenfire1-lanzhou" || decoded.Groups[0].Source != "msglib_readonly" || decoded.Groups[0].RawRef != "msglib:TD_WorkGroupAuth" {
t.Fatalf("first group should be exact MsgLib match with source metadata, got %+v", decoded.Groups[0])
}
if decoded.Groups[1].GroupID != "zzz-target@conference.imopenfire1-lanzhou" {
t.Fatalf("second group should be substring match, got %+v", decoded.Groups[1])
}
}
func TestISphereSearchGroupsToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-group-1",
ConversationType: "groupchat",
SenderID: "project-room@conference.imopenfire1-lanzhou",
ReceiverID: "user@imopenfire1-lanzhou",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereGroupTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{
"query": "project",
"source_preference": "local_readonly",
"include_archived": true,
"cursor": "",
"limit": 10,
},
})
if err != nil {
t.Fatalf("call with safe contract args: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "project", "source_preference": "bridge"},
})
if err == nil && !bridgeResult.IsError {
t.Fatalf("bridge source_preference was accepted before connector exists")
}
cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "project", "cursor": "next-page"},
})
if err == nil && !cursorResult.IsError {
t.Fatalf("non-empty cursor was accepted before pagination exists")
}
}

View File

@@ -0,0 +1,342 @@
package tools
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
const ToolNameReceiveMessages = "isphere_receive_messages"
type ReceiveMessagesSource interface {
ReceiveMessages(ctx context.Context, query isphere.ReceiveMessagesQuery) (isphere.ReceiveMessagesResult, error)
}
type ReceiveMessagesArgs struct {
ConversationID string `json:"conversation_id,omitempty" jsonschema:"exact conversation id filter for local readonly message logs"`
Query string `json:"query,omitempty" jsonschema:"case-insensitive keyword filter over message text, subject, ids, and participants"`
Since string `json:"since,omitempty" jsonschema:"RFC3339 timestamp; return messages at or after this time when packet timestamps are available"`
Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"`
SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"`
Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility; read-only execution remains read mode"`
IncludeAttachmentMetadata *bool `json:"include_attachment_metadata,omitempty" jsonschema:"include inline attachment metadata in receive message results; default true"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of messages to return; zero or negative returns all available messages"`
}
func RegisterISphereReadTools(server *mcp.Server, source ReceiveMessagesSource) {
RegisterISphereReadToolsWithDisplayEntities(server, source, nil)
}
func RegisterISphereReadToolsWithDisplayEntities(server *mcp.Server, source ReceiveMessagesSource, displaySource DisplayEntitySource) {
RegisterISphereReadToolsWithReceiveSources(server, source, nil, displaySource)
}
func RegisterISphereReadToolsWithReceiveSources(server *mcp.Server, source ReceiveMessagesSource, msglibSource ReceiveMessagesSource, displaySource DisplayEntitySource) {
if source == nil {
source = isphere.EncryptedPacketLogSource{}
}
mcp.AddTool[ReceiveMessagesArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameReceiveMessages,
Description: "Return iSphere messages from the configured read-only log-backed source.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveMessagesArgs) (*mcp.CallToolResult, map[string]any, error) {
if err := validateReceiveMessagesCursor(input); err != nil {
return nil, nil, err
}
selectedSource, err := receiveMessagesSourceForPreference(input.SourcePreference, source, msglibSource)
if err != nil {
return nil, nil, err
}
started := time.Now().UTC()
result, err := selectedSource.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{
ConversationID: input.ConversationID,
Query: input.Query,
Since: input.Since,
Limit: input.Limit,
})
if err != nil {
return nil, nil, err
}
displayNames := receiveMessageDisplayNames{}
if displaySource != nil && len(result.Messages) > 0 {
loaded, err := loadReceiveMessageDisplayNames(ctx, displaySource)
if err != nil {
return nil, nil, err
}
displayNames = loaded
}
return nil, receiveMessagesResultToMap(result, started, time.Now().UTC(), receiveMessagesMapOptions{
IncludeAttachmentMetadata: includeReceiveMessageAttachmentMetadata(input),
DisplayNames: displayNames,
}), nil
})
}
func validateReceiveMessagesCursor(input ReceiveMessagesArgs) error {
if strings.TrimSpace(input.Cursor) != "" {
return fmt.Errorf("%s cursor pagination is not available yet", ToolNameReceiveMessages)
}
return nil
}
func receiveMessagesSourceForPreference(sourcePreferenceValue string, primarySource ReceiveMessagesSource, msglibSource ReceiveMessagesSource) (ReceiveMessagesSource, error) {
sourcePreference := strings.ToLower(strings.TrimSpace(sourcePreferenceValue))
switch sourcePreference {
case "", "auto", "local_readonly":
return primarySource, nil
case "msglib_readonly":
if msglibSource == nil {
return nil, fmt.Errorf("%s source_preference %q is not configured", ToolNameReceiveMessages, sourcePreferenceValue)
}
return msglibSource, nil
default:
return nil, fmt.Errorf("%s source_preference %q is not available; only auto, local_readonly, and msglib_readonly are supported", ToolNameReceiveMessages, sourcePreferenceValue)
}
}
func validateLocalReadonlySourceAndCursor(toolName string, sourcePreferenceValue string, cursorValue string) error {
sourcePreference := strings.ToLower(strings.TrimSpace(sourcePreferenceValue))
switch sourcePreference {
case "", "auto", "local_readonly":
default:
return fmt.Errorf("%s source_preference %q is not available; only auto and local_readonly are supported", toolName, sourcePreferenceValue)
}
if strings.TrimSpace(cursorValue) != "" {
return fmt.Errorf("%s cursor pagination is not available yet", toolName)
}
return nil
}
func includeReceiveMessageAttachmentMetadata(input ReceiveMessagesArgs) bool {
if input.IncludeAttachmentMetadata == nil {
return true
}
return *input.IncludeAttachmentMetadata
}
type receiveMessagesMapOptions struct {
IncludeAttachmentMetadata bool
DisplayNames receiveMessageDisplayNames
}
type receiveMessageDisplayNames struct {
Contacts map[string]string
Groups map[string]string
}
func receiveMessagesResultToMap(result isphere.ReceiveMessagesResult, started time.Time, finished time.Time, options receiveMessagesMapOptions) map[string]any {
messages := make([]map[string]any, 0, len(result.Messages))
for _, message := range result.Messages {
attachments := []map[string]any{}
if options.IncludeAttachmentMetadata {
attachments = receiveMessageAttachments(message)
}
senderName := nullableDisplayName(options.DisplayNames.Contacts, message.SenderID)
messages = append(messages, map[string]any{
"id": message.ID,
"message_id": message.ID,
"conversation_id": message.ConversationID,
"conversation_type": message.ConversationType,
"sender_id": message.SenderID,
"sender_name": senderName,
"receiver_id": message.ReceiverID,
"text": message.Text,
"content_type": receiveMessageContentType(message, attachments),
"content_text": message.Text,
"attachments": attachments,
"timestamp": message.Timestamp,
"created_at": packetTimestampToRFC3339(message.Timestamp),
"received_at": nil,
"subject": message.Subject,
"receipt_id": message.ReceiptID,
"read": message.Read,
"source": receiveMessageSource(message),
"raw_ref": receiveMessageRawRef(message),
})
}
return map[string]any{
"ok": true,
"conversation": receiveMessagesConversation(result.Messages, options.DisplayNames),
"messages": messages,
"next_cursor": nil,
"audit": map[string]any{
"tool": ToolNameReceiveMessages,
"source": "local_readonly",
"execution_mode": "read",
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": "ok",
},
}
}
func receiveMessagesConversation(messages []isphere.Message, displayNames receiveMessageDisplayNames) any {
if len(messages) == 0 {
return nil
}
first := messages[0]
conversationType := contractConversationType(first.ConversationType)
displayName := first.ConversationID
if conversationType == "group" {
if name := lookupDisplayName(displayNames.Groups, first.SenderID, first.ReceiverID, first.ConversationID); name != "" {
displayName = name
}
} else if name := lookupDisplayName(displayNames.Contacts, first.SenderID, first.ReceiverID); name != "" {
displayName = name
}
return map[string]any{
"conversation_id": first.ConversationID,
"conversation_type": conversationType,
"display_name": displayName,
}
}
func loadReceiveMessageDisplayNames(ctx context.Context, source DisplayEntitySource) (receiveMessageDisplayNames, error) {
contacts, err := source.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{EntityType: msglib.EntityTypeContacts, Limit: msglib.MaxDisplayEntityLimit})
if err != nil {
return receiveMessageDisplayNames{}, err
}
groups, err := source.DisplayEntities(ctx, msglib.DisplayEntitiesOptions{EntityType: msglib.EntityTypeGroups, Limit: msglib.MaxDisplayEntityLimit})
if err != nil {
return receiveMessageDisplayNames{}, err
}
return receiveMessageDisplayNames{
Contacts: displayEntityNameMap(contacts, msglib.EntityTypeContacts),
Groups: displayEntityNameMap(groups, msglib.EntityTypeGroups),
}, nil
}
func displayEntityNameMap(entities []msglib.DisplayEntity, entityType string) map[string]string {
names := map[string]string{}
for _, entity := range entities {
if entity.EntityType != "" && entity.EntityType != entityType {
continue
}
jid := strings.ToLower(strings.TrimSpace(entity.JID))
name := strings.TrimSpace(entity.DisplayName)
if jid == "" || name == "" {
continue
}
if _, exists := names[jid]; !exists {
names[jid] = name
}
}
return names
}
func nullableDisplayName(names map[string]string, id string) any {
if name := lookupDisplayName(names, id); name != "" {
return name
}
return nil
}
func lookupDisplayName(names map[string]string, candidates ...string) string {
if len(names) == 0 {
return ""
}
for _, candidate := range candidates {
for _, part := range strings.Split(candidate, "|") {
key := strings.ToLower(strings.TrimSpace(part))
if key == "" {
continue
}
if name := names[key]; name != "" {
return name
}
}
}
return ""
}
func contractConversationType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "groupchat", "group":
return "group"
case "chat", "direct":
return "direct"
case "system":
return "system"
default:
return "unknown"
}
}
func receiveMessageAttachments(message isphere.Message) []map[string]any {
if len(message.Attachments) > 0 {
attachments := make([]map[string]any, 0, len(message.Attachments))
for _, attachment := range message.Attachments {
attachments = append(attachments, map[string]any{
"file_id": attachment.FileID,
"file_name": attachment.FileName,
"size_bytes": attachment.SizeBytes,
"download_ref": nullableString(attachment.DownloadRef),
})
}
return attachments
}
files := isphere.ListFilesFromMessages([]isphere.Message{message}, isphere.ListFilesQuery{})
attachments := make([]map[string]any, 0, len(files.Files))
for _, file := range files.Files {
attachments = append(attachments, map[string]any{
"file_id": file.FileID,
"file_name": file.FileName,
"size_bytes": file.SizeBytes,
"download_ref": nullableString(file.DownloadRef),
})
}
return attachments
}
func receiveMessageSource(message isphere.Message) string {
if strings.TrimSpace(message.Source) != "" {
return message.Source
}
return "local_readonly"
}
func receiveMessageRawRef(message isphere.Message) string {
if strings.TrimSpace(message.RawRef) != "" {
return message.RawRef
}
return "packetlog_message"
}
func receiveMessageContentType(message isphere.Message, attachments []map[string]any) string {
if len(attachments) > 0 {
return "file"
}
if strings.TrimSpace(message.Text) != "" {
return "text"
}
if strings.TrimSpace(message.Subject) != "" {
return "notify"
}
return "unknown"
}
func packetTimestampToRFC3339(value string) any {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
epoch, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return nil
}
var ts time.Time
if epoch > 1_000_000_000_000 {
ts = time.UnixMilli(epoch)
} else {
ts = time.Unix(epoch, 0)
}
formatted := ts.UTC().Format(time.RFC3339)
return formatted
}

View File

@@ -0,0 +1,389 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/isphere"
"isphere-ai-bridge/internal/msglib"
)
func TestISphereReceiveMessagesToolReturnsSourceMessages(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
ConversationID: "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou",
ConversationType: "chat",
SenderID: "sender@imopenfire1-lanzhou",
ReceiverID: "receiver@imopenfire1-lanzhou",
Text: "redacted-report.docx",
Timestamp: "1783423807000",
Subject: "FILE_TRANSFER_CANCEL",
ReceiptID: "receipt-1",
Read: true,
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereReadTools(server, fake)
})
defer cleanup()
listResult, err := session.ListTools(context.Background(), &mcp.ListToolsParams{})
if err != nil {
t.Fatalf("list tools: %v", err)
}
gotNames := make([]string, 0, len(listResult.Tools))
for _, tool := range listResult.Tools {
gotNames = append(gotNames, tool.Name)
}
assertSameStrings(t, gotNames, []string{ToolNameReceiveMessages})
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"conversation_id": "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou", "query": "REPORT", "since": "2026-07-07T11:30:00Z", "limit": 5},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameReceiveMessages, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
if len(fake.queries) != 1 || fake.queries[0].Limit != 5 || fake.queries[0].ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" || fake.queries[0].Query != "REPORT" || fake.queries[0].Since != "2026-07-07T11:30:00Z" {
t.Fatalf("queries = %+v, want conversation_id/query/since/limit", fake.queries)
}
var decoded struct {
OK bool `json:"ok"`
NextCursor *string `json:"next_cursor"`
Conversation *struct {
ConversationID string `json:"conversation_id"`
ConversationType string `json:"conversation_type"`
DisplayName string `json:"display_name"`
} `json:"conversation"`
Messages []struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
ConversationID string `json:"conversation_id"`
ConversationType string `json:"conversation_type"`
SenderID string `json:"sender_id"`
SenderName *string `json:"sender_name"`
ReceiverID string `json:"receiver_id"`
Text string `json:"text"`
ContentType string `json:"content_type"`
ContentText string `json:"content_text"`
Attachments []struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
DownloadRef any `json:"download_ref"`
} `json:"attachments"`
Timestamp string `json:"timestamp"`
CreatedAt *string `json:"created_at"`
ReceivedAt any `json:"received_at"`
Subject string `json:"subject"`
ReceiptID string `json:"receipt_id"`
Read bool `json:"read"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"messages"`
Audit struct {
Tool string `json:"tool"`
Source string `json:"source"`
ExecutionMode string `json:"execution_mode"`
Result string `json:"result"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
} `json:"audit"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 {
t.Fatalf("messages = %+v, want one", decoded.Messages)
}
if !decoded.OK || decoded.NextCursor != nil {
t.Fatalf("ok/next_cursor = %v/%v", decoded.OK, decoded.NextCursor)
}
if decoded.Conversation == nil {
t.Fatalf("conversation missing")
}
if decoded.Conversation.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" || decoded.Conversation.DisplayName == "" {
t.Fatalf("unexpected conversation: %+v", decoded.Conversation)
}
msg := decoded.Messages[0]
if msg.ID != "msg-1" || msg.MessageID != "msg-1" || msg.Text != "redacted-report.docx" || msg.ContentText != "redacted-report.docx" || msg.Subject != "FILE_TRANSFER_CANCEL" {
t.Fatalf("unexpected message content: %+v", msg)
}
if msg.ContentType != "file" {
t.Fatalf("content_type = %q, want file", msg.ContentType)
}
if len(msg.Attachments) != 1 || msg.Attachments[0].FileID != "msg-1:redacted-report.docx" || msg.Attachments[0].FileName != "redacted-report.docx" || msg.Attachments[0].DownloadRef != nil {
t.Fatalf("attachments = %+v", msg.Attachments)
}
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
t.Fatalf("conversation_id = %q", msg.ConversationID)
}
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
}
if msg.Timestamp != "1783423807000" || msg.CreatedAt == nil || *msg.CreatedAt != "2026-07-07T11:30:07Z" || msg.ReceiptID != "receipt-1" || !msg.Read {
t.Fatalf("metadata = %+v", msg)
}
if msg.SenderName != nil || msg.ReceivedAt != nil || msg.Source != "local_readonly" || msg.RawRef != "packetlog_message" {
t.Fatalf("contract metadata = %+v", msg)
}
if decoded.Audit.Tool != ToolNameReceiveMessages || decoded.Audit.Source != "local_readonly" || decoded.Audit.ExecutionMode != "read" || decoded.Audit.Result != "ok" || decoded.Audit.StartedAt == "" || decoded.Audit.FinishedAt == "" {
t.Fatalf("unexpected audit: %+v", decoded.Audit)
}
}
func TestISphereReceiveMessagesToolUsesInjectedDisplayEntities(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-display-1",
ConversationID: "project-room@conference|sender@example",
ConversationType: "groupchat",
SenderID: "sender@example",
ReceiverID: "project-room@conference",
Text: "keep this body unchanged",
Timestamp: "1783423807000",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "sender@example", DisplayName: "Sender Name", Confidence: 0.9},
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "project-room@conference", DisplayName: "Project Room", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereReadToolsWithDisplayEntities(server, fake, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"query": "body", "limit": 5},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameReceiveMessages, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Conversation *struct {
ConversationID string `json:"conversation_id"`
ConversationType string `json:"conversation_type"`
DisplayName string `json:"display_name"`
} `json:"conversation"`
Messages []struct {
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name"`
Text string `json:"text"`
ContentText string `json:"content_text"`
} `json:"messages"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if decoded.Conversation == nil || decoded.Conversation.DisplayName != "Project Room" || decoded.Conversation.ConversationType != "group" {
t.Fatalf("unexpected conversation: %+v", decoded.Conversation)
}
if len(decoded.Messages) != 1 {
t.Fatalf("messages = %+v, want one", decoded.Messages)
}
msg := decoded.Messages[0]
if msg.SenderID != "sender@example" || msg.SenderName != "Sender Name" {
t.Fatalf("unexpected sender fields: %+v", msg)
}
if msg.Text != "keep this body unchanged" || msg.ContentText != "keep this body unchanged" {
t.Fatalf("message text changed: %+v", msg)
}
if len(fakeDisplay.queries) != 2 {
t.Fatalf("display queries = %+v, want contacts and groups lookup", fakeDisplay.queries)
}
if fakeDisplay.queries[0].EntityType != msglib.EntityTypeContacts || fakeDisplay.queries[1].EntityType != msglib.EntityTypeGroups {
t.Fatalf("display query entity types = %+v", fakeDisplay.queries)
}
}
func TestISphereReceiveMessagesToolSelectsExplicitMsgLibSource(t *testing.T) {
primary := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "packet-msg-1",
ConversationID: "packet-conversation",
ConversationType: "chat",
SenderID: "packet-sender",
ReceiverID: "packet-receiver",
Text: "packet body",
Timestamp: "1783423807000",
}}},
}
msglibSource := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "db-msg-1",
ConversationID: "db-conversation",
ConversationType: "group",
SenderID: "db-sender",
ReceiverID: "",
Text: "db body",
Timestamp: "1783423808000",
Source: "local_readonly",
RawRef: "msglib:tblMsgGroupPersonMsg",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereReadToolsWithReceiveSources(server, primary, msglibSource, nil)
})
defer cleanup()
defaultResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"limit": 5},
})
if err != nil {
t.Fatalf("default call %s: %v", ToolNameReceiveMessages, err)
}
defaultPayload := decodeReceiveMessagesForSelectionTest(t, defaultResult.StructuredContent)
if len(defaultPayload.Messages) != 1 || defaultPayload.Messages[0].ID != "packet-msg-1" {
t.Fatalf("default messages = %+v, want packet source", defaultPayload.Messages)
}
if len(primary.queries) != 1 || len(msglibSource.queries) != 0 {
t.Fatalf("default source calls primary=%d msglib=%d", len(primary.queries), len(msglibSource.queries))
}
dbResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"source_preference": "msglib_readonly", "query": "db", "limit": 3},
})
if err != nil {
t.Fatalf("msglib call %s: %v", ToolNameReceiveMessages, err)
}
dbPayload := decodeReceiveMessagesForSelectionTest(t, dbResult.StructuredContent)
if len(dbPayload.Messages) != 1 || dbPayload.Messages[0].ID != "db-msg-1" || dbPayload.Messages[0].RawRef != "msglib:tblMsgGroupPersonMsg" {
t.Fatalf("db messages = %+v, want explicit msglib source", dbPayload.Messages)
}
if len(msglibSource.queries) != 1 || msglibSource.queries[0].Query != "db" || msglibSource.queries[0].Limit != 3 {
t.Fatalf("msglib source queries = %+v", msglibSource.queries)
}
missingMsgLibSession, missingCleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereReadToolsWithReceiveSources(server, primary, nil, nil)
})
defer missingCleanup()
missingResult, err := missingMsgLibSession.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"source_preference": "msglib_readonly", "limit": 1},
})
if err == nil && !missingResult.IsError {
t.Fatalf("explicit msglib source was accepted without a configured MsgLib receive source")
}
}
func TestISphereReceiveMessagesToolValidatesRemainingContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
ConversationID: "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou",
ConversationType: "chat",
SenderID: "sender@imopenfire1-lanzhou",
ReceiverID: "receiver@imopenfire1-lanzhou",
Text: "redacted-report.docx",
Timestamp: "1783423807000",
Subject: "FILE_TRANSFER_CANCEL",
}}},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereReadTools(server, fake)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{
"include_attachment_metadata": false,
"source_preference": "local_readonly",
"preview": true,
"cursor": "",
"limit": 5,
},
})
if err != nil {
t.Fatalf("call with safe contract args: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Messages []struct {
Attachments []struct {
FileID string `json:"file_id"`
} `json:"attachments"`
} `json:"messages"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 || len(decoded.Messages[0].Attachments) != 0 {
t.Fatalf("attachments = %+v, want suppressed", decoded.Messages)
}
bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"source_preference": "bridge", "limit": 5},
})
if err == nil && !bridgeResult.IsError {
t.Fatalf("bridge source_preference was accepted before connector exists")
}
cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameReceiveMessages,
Arguments: map[string]any{"cursor": "next-page", "limit": 5},
})
if err == nil && !cursorResult.IsError {
t.Fatalf("non-empty cursor was accepted before pagination exists")
}
}
func decodeReceiveMessagesForSelectionTest(t *testing.T, structured any) struct {
Messages []struct {
ID string `json:"id"`
RawRef string `json:"raw_ref"`
} `json:"messages"`
} {
t.Helper()
var decoded struct {
Messages []struct {
ID string `json:"id"`
RawRef string `json:"raw_ref"`
} `json:"messages"`
}
payload, err := json.Marshal(structured)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
return decoded
}
type fakeReceiveMessagesSource struct {
queries []isphere.ReceiveMessagesQuery
result isphere.ReceiveMessagesResult
}
func (f *fakeReceiveMessagesSource) ReceiveMessages(_ context.Context, query isphere.ReceiveMessagesQuery) (isphere.ReceiveMessagesResult, error) {
f.queries = append(f.queries, query)
return f.result, nil
}

View File

@@ -0,0 +1,542 @@
package tools
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
ToolNameSendFile = "isphere_send_file"
EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR"
EnvSendFileAuditPath = "ISPHERE_SEND_FILE_AUDIT_PATH"
EnvSendFileIdempotencyPath = "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"
sendFileConnector = "implatform-file-transfer"
sendFileConnectorMode = "preview-only"
sendFilePreviewStage = "preview"
sendFileBlockedStage = "blocked"
sendFileProductionMode = "production"
)
type SendFileArgs struct {
TargetType string `json:"target_type" jsonschema:"target type; direct/contact or group/groupchat"`
TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"`
FilePath string `json:"file_path" jsonschema:"local file path under ISPHERE_SEND_FILE_ALLOWED_DIR to hash and preview"`
FileSHA256 string `json:"file_sha256,omitempty" jsonschema:"optional lowercase SHA256 hex of file contents; checked when supplied"`
IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; returned only as SHA256"`
ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview/dry_run or production; production is blocked until file-send evidence passes"`
Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility; current implementation is preview-only"`
}
type normalizedSendFileArgs struct {
TargetType string
TargetID string
TargetRef string
FileName string
FileSHA256 string
FileSizeBytes int64
IdempotencyKeySHA256 string
ExecutionMode string
}
type SendFileAuditSink interface {
AppendSendFileAudit(ctx context.Context, event SendFileAuditEvent) error
}
type SendFileIdempotencyStore interface {
ReserveSendFileIdempotency(ctx context.Context, record SendFileIdempotencyRecord) (SendFileIdempotencyDecision, error)
}
type SendFileAuditEvent struct {
Tool string `json:"tool"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetRef string `json:"target_ref"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorMode string `json:"connector_mode"`
ConnectorStage string `json:"connector_stage"`
FileSHA256 string `json:"file_sha256"`
FileSizeBytes int64 `json:"file_size_bytes"`
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
SendStatus string `json:"send_status"`
Result string `json:"result"`
BlockedReason string `json:"blocked_reason,omitempty"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
RealSendAttempted bool `json:"real_send_attempted"`
SideEffects map[string]any `json:"side_effects"`
FilePath string `json:"-"`
IdempotencyKey string `json:"-"`
}
type SendFileIdempotencyRecord struct {
Tool string `json:"tool"`
TargetRef string `json:"target_ref"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorStage string `json:"connector_stage"`
FileSHA256 string `json:"file_sha256"`
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
}
type SendFileIdempotencyDecision struct {
Duplicate bool
Conflict bool
FirstAuditRef string
FirstStartedAt string
}
type fileSendFileAuditSink struct {
path string
}
type fileSendFileIdempotencyStore struct {
path string
}
var sendFileIdempotencyFileMu sync.Mutex
func RegisterISphereSendFileTool(server *mcp.Server) {
RegisterISphereSendFileToolWithState(server, nil, nil)
}
func RegisterISphereSendFileToolWithState(server *mcp.Server, auditSink SendFileAuditSink, idempotencyStore SendFileIdempotencyStore) {
if auditSink == nil {
auditSink = defaultSendFileAuditSink()
}
if idempotencyStore == nil {
idempotencyStore = defaultSendFileIdempotencyStore()
}
mcp.AddTool[SendFileArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameSendFile,
Description: "Preview iSphere file sends with target/file hash/idempotency metadata; production file upload is blocked until sandbox evidence passes.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendFileArgs) (*mcp.CallToolResult, map[string]any, error) {
started := time.Now().UTC()
normalized, blockedReason, err := normalizeSendFileArgs(input)
if err != nil {
return nil, nil, err
}
var response map[string]any
var event SendFileAuditEvent
if blockedReason != "" {
response, event = sendFileResponse(normalized, started, time.Now().UTC(), false, blockedReason)
} else if normalized.ExecutionMode == sendFileProductionMode {
response, event = sendFileResponse(normalized, started, time.Now().UTC(), false, "production file upload is blocked until send-file sandbox evidence passes")
} else {
response, event = sendFileResponse(normalized, started, time.Now().UTC(), true, "")
}
if normalized.FileSHA256 != "" {
decision, err := idempotencyStore.ReserveSendFileIdempotency(ctx, sendFileIdempotencyRecordFromAuditEvent(event))
if err != nil {
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendFile, err)
}
applySendFileIdempotencyDecision(response, &event, decision)
}
if err := auditSink.AppendSendFileAudit(ctx, event); err != nil {
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendFile, err)
}
return nil, response, nil
})
}
func defaultSendFileAuditSink() SendFileAuditSink {
if path := strings.TrimSpace(os.Getenv(EnvSendFileAuditPath)); path != "" {
return fileSendFileAuditSink{path: path}
}
return fileSendFileAuditSink{path: filepath.Join("runs", "send-audit", "send-file-preview.jsonl")}
}
func defaultSendFileIdempotencyStore() SendFileIdempotencyStore {
if path := strings.TrimSpace(os.Getenv(EnvSendFileIdempotencyPath)); path != "" {
return fileSendFileIdempotencyStore{path: path}
}
return fileSendFileIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-file-idempotency.jsonl")}
}
func (s fileSendFileAuditSink) AppendSendFileAudit(_ context.Context, event SendFileAuditEvent) error {
if strings.TrimSpace(s.path) == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
payload, err := json.Marshal(event)
if err != nil {
return err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(append(payload, '\n')); err != nil {
return err
}
return nil
}
func (s fileSendFileIdempotencyStore) ReserveSendFileIdempotency(_ context.Context, record SendFileIdempotencyRecord) (SendFileIdempotencyDecision, error) {
if strings.TrimSpace(s.path) == "" {
return SendFileIdempotencyDecision{}, nil
}
if record.IdempotencyKeySHA256 == "" {
return SendFileIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required")
}
sendFileIdempotencyFileMu.Lock()
defer sendFileIdempotencyFileMu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return SendFileIdempotencyDecision{}, err
}
existing, err := readSendFileIdempotencyRecords(s.path)
if err != nil {
return SendFileIdempotencyDecision{}, err
}
for _, candidate := range existing {
if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
continue
}
decision := SendFileIdempotencyDecision{
FirstAuditRef: candidate.AuditRef,
FirstStartedAt: candidate.StartedAt,
}
if candidate.TargetRef == record.TargetRef && candidate.FileSHA256 == record.FileSHA256 {
decision.Duplicate = true
return decision, nil
}
decision.Conflict = true
return decision, nil
}
payload, err := json.Marshal(record)
if err != nil {
return SendFileIdempotencyDecision{}, err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return SendFileIdempotencyDecision{}, err
}
defer file.Close()
if _, err := file.Write(append(payload, '\n')); err != nil {
return SendFileIdempotencyDecision{}, err
}
return SendFileIdempotencyDecision{}, nil
}
func readSendFileIdempotencyRecords(path string) ([]SendFileIdempotencyRecord, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
var records []SendFileIdempotencyRecord
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var record SendFileIdempotencyRecord
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, fmt.Errorf("decode send-file idempotency record: %w", err)
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return records, nil
}
func normalizeSendFileArgs(input SendFileArgs) (normalizedSendFileArgs, string, error) {
targetType, targetRefPrefix, err := normalizeSendFileTargetType(input.TargetType)
if err != nil {
return normalizedSendFileArgs{}, "", err
}
targetID := strings.TrimSpace(input.TargetID)
if targetID == "" {
return normalizedSendFileArgs{}, "", fmt.Errorf("%s target_id is required", ToolNameSendFile)
}
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
if idempotencyKey == "" {
return normalizedSendFileArgs{}, "", fmt.Errorf("%s idempotency_key is required", ToolNameSendFile)
}
mode, err := normalizeSendFileExecutionMode(input.ExecutionMode)
if err != nil {
return normalizedSendFileArgs{}, "", err
}
normalized := normalizedSendFileArgs{
TargetType: targetType,
TargetID: targetID,
TargetRef: targetRefPrefix + ":" + targetID,
FileName: filepath.Base(strings.TrimSpace(input.FilePath)),
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
ExecutionMode: mode,
}
fileSHA256, fileSize, blockedReason, err := inspectSendFilePath(input.FilePath, input.FileSHA256)
if err != nil {
return normalizedSendFileArgs{}, "", err
}
normalized.FileSHA256 = fileSHA256
normalized.FileSizeBytes = fileSize
return normalized, blockedReason, nil
}
func normalizeSendFileTargetType(value string) (string, string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "direct", "contact":
return "direct", "contact", nil
case "group", "groupchat":
return "group", "group", nil
default:
return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendFile, value)
}
}
func normalizeSendFileExecutionMode(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "preview", "dry_run", "dry-run":
return "preview", nil
case sendFileProductionMode:
return sendFileProductionMode, nil
default:
return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendFile, value)
}
}
func inspectSendFilePath(filePath string, providedSHA256 string) (string, int64, string, error) {
trimmedPath := strings.TrimSpace(filePath)
if trimmedPath == "" {
return "", 0, "", fmt.Errorf("%s file_path is required", ToolNameSendFile)
}
allowedDir := strings.TrimSpace(os.Getenv(EnvSendFileAllowedDir))
if allowedDir == "" {
return "", 0, fmt.Sprintf("%s is required before previewing local file paths", EnvSendFileAllowedDir), nil
}
allowedAbs, err := filepath.Abs(allowedDir)
if err != nil {
return "", 0, "", fmt.Errorf("%s allowed dir: %w", ToolNameSendFile, err)
}
fileAbs, err := filepath.Abs(trimmedPath)
if err != nil {
return "", 0, "", fmt.Errorf("%s file_path: %w", ToolNameSendFile, err)
}
if !sendFilePathInsideDir(fileAbs, allowedAbs) {
return "", 0, fmt.Sprintf("file_path must stay inside %s", EnvSendFileAllowedDir), nil
}
info, err := os.Stat(fileAbs)
if err != nil {
if os.IsNotExist(err) {
return "", 0, "file_path does not exist under allowed directory", nil
}
return "", 0, "", fmt.Errorf("%s stat file_path: %w", ToolNameSendFile, err)
}
if info.IsDir() {
return "", 0, "file_path points to a directory; choose one file", nil
}
fileHash, err := sha256FileHex(fileAbs)
if err != nil {
return "", 0, "", fmt.Errorf("%s hash file_path: %w", ToolNameSendFile, err)
}
normalizedProvided := strings.ToLower(strings.TrimSpace(providedSHA256))
if normalizedProvided != "" {
if len(normalizedProvided) != 64 || !isLowerHex(normalizedProvided) {
return "", 0, "", fmt.Errorf("%s file_sha256 must be a lowercase SHA256 hex value", ToolNameSendFile)
}
if normalizedProvided != fileHash {
return "", 0, "", fmt.Errorf("%s file_sha256 does not match file contents", ToolNameSendFile)
}
}
return fileHash, info.Size(), "", nil
}
func sendFilePathInsideDir(fileAbs string, allowedAbs string) bool {
rel, err := filepath.Rel(filepath.Clean(allowedAbs), filepath.Clean(fileAbs))
if err != nil {
return false
}
if rel == "." {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)
}
func sha256FileHex(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) (map[string]any, SendFileAuditEvent) {
sideEffects := sendMessageNoSideEffects()
status := "planned"
stage := sendFilePreviewStage
ok := true
blocked := any(nil)
if !planned {
status = "blocked"
stage = sendFileBlockedStage
ok = false
blocked = blockedReason
}
auditRef := "send-file:" + shortHash(input.FileSHA256) + ":" + shortHash(input.IdempotencyKeySHA256)
event := SendFileAuditEvent{
Tool: ToolNameSendFile,
TargetType: input.TargetType,
TargetID: input.TargetID,
TargetRef: input.TargetRef,
ExecutionMode: input.ExecutionMode,
Connector: sendFileConnector,
ConnectorMode: sendFileConnectorMode,
ConnectorStage: stage,
FileSHA256: input.FileSHA256,
FileSizeBytes: input.FileSizeBytes,
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
SendStatus: status,
Result: status,
AuditRef: auditRef,
StartedAt: started.Format(time.RFC3339Nano),
FinishedAt: finished.Format(time.RFC3339Nano),
RealSendAttempted: false,
SideEffects: sideEffects,
}
if blockedReason != "" {
event.BlockedReason = blockedReason
}
response := map[string]any{
"ok": ok,
"send_status": status,
"blocked_reason": blocked,
"execution_mode": input.ExecutionMode,
"connector": sendFileConnector,
"connector_mode": sendFileConnectorMode,
"connector_stage": stage,
"production_send_enabled": false,
"real_send_attempted": false,
"target_ref": input.TargetRef,
"file_sha256": input.FileSHA256,
"file_size_bytes": input.FileSizeBytes,
"target": map[string]any{
"target_type": input.TargetType,
"target_id": input.TargetID,
"target_ref": input.TargetRef,
},
"file": map[string]any{
"file_name": input.FileName,
"file_sha256": input.FileSHA256,
"file_size_bytes": input.FileSizeBytes,
"allowed_dir_enforced": true,
"file_content_returned": false,
},
"idempotency": map[string]any{
"idempotency_key_sha256": input.IdempotencyKeySHA256,
"duplicate_detected": false,
"conflict_detected": false,
"first_audit_ref": nil,
"first_started_at": nil,
},
"side_effect_flags": sideEffects,
"side_effects": sideEffects,
"audit": map[string]any{
"tool": ToolNameSendFile,
"source": sendFileConnector,
"execution_mode": input.ExecutionMode,
"connector": sendFileConnector,
"connector_mode": sendFileConnectorMode,
"connector_stage": stage,
"file_sha256": input.FileSHA256,
"file_size_bytes": input.FileSizeBytes,
"target_ref": input.TargetRef,
"audit_ref": auditRef,
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": status,
"real_send_attempted": false,
},
}
return response, event
}
func sendFileIdempotencyRecordFromAuditEvent(event SendFileAuditEvent) SendFileIdempotencyRecord {
return SendFileIdempotencyRecord{
Tool: event.Tool,
TargetRef: event.TargetRef,
ExecutionMode: event.ExecutionMode,
Connector: event.Connector,
ConnectorStage: event.ConnectorStage,
FileSHA256: event.FileSHA256,
IdempotencyKeySHA256: event.IdempotencyKeySHA256,
AuditRef: event.AuditRef,
StartedAt: event.StartedAt,
}
}
func applySendFileIdempotencyDecision(response map[string]any, event *SendFileAuditEvent, decision SendFileIdempotencyDecision) {
idempotency, _ := response["idempotency"].(map[string]any)
if idempotency == nil {
idempotency = map[string]any{}
response["idempotency"] = idempotency
}
idempotency["duplicate_detected"] = decision.Duplicate
idempotency["conflict_detected"] = decision.Conflict
if decision.FirstAuditRef != "" {
idempotency["first_audit_ref"] = decision.FirstAuditRef
}
if decision.FirstStartedAt != "" {
idempotency["first_started_at"] = decision.FirstStartedAt
}
if !decision.Conflict {
return
}
blockedReason := "idempotency key was already used for a different target or file"
response["ok"] = false
response["send_status"] = "blocked"
response["blocked_reason"] = blockedReason
response["connector_stage"] = sendFileBlockedStage
audit, _ := response["audit"].(map[string]any)
if audit != nil {
audit["result"] = "blocked"
audit["connector_stage"] = sendFileBlockedStage
audit["blocked_reason"] = blockedReason
}
if event != nil {
event.ConnectorStage = sendFileBlockedStage
event.SendStatus = "blocked"
event.Result = "blocked"
event.BlockedReason = blockedReason
}
}

View File

@@ -0,0 +1,351 @@
package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestISphereSendFilePreviewComputesHashAndSize(t *testing.T) {
allowedDir := t.TempDir()
filePath := filepath.Join(allowedDir, "hello.txt")
if err := os.WriteFile(filePath, []byte("hello"), 0o600); err != nil {
t.Fatalf("write fixture file: %v", err)
}
t.Setenv(EnvSendFileAllowedDir, allowedDir)
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendFileTool(server)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendFile,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": sha256HexForSendMessageTest("hello"),
"idempotency_key": "file-preview-idem-1",
"execution_mode": "preview",
"preview": true,
},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSendFile, err)
}
if callResult.IsError {
t.Fatalf("preview should be structured success, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
FileSHA256 string `json:"file_sha256"`
FileSizeBytes int64 `json:"file_size_bytes"`
TargetRef string `json:"target_ref"`
ProductionSendEnabled bool `json:"production_send_enabled"`
RealSendAttempted bool `json:"real_send_attempted"`
SideEffectFlags map[string]any `json:"side_effect_flags"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode send-file preview payload %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "planned" {
t.Fatalf("preview status mismatch: %s", payload)
}
if decoded.FileSHA256 != sha256HexForSendMessageTest("hello") || decoded.FileSizeBytes != 5 {
t.Fatalf("file metadata mismatch: %s", payload)
}
if decoded.TargetRef != "contact:alice@imopenfire1-lanzhou" {
t.Fatalf("target_ref = %q, want contact ref", decoded.TargetRef)
}
if decoded.ProductionSendEnabled || decoded.RealSendAttempted {
t.Fatalf("preview must not enable or attempt real send: %s", payload)
}
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["clicked_ui"] != false {
t.Fatalf("side-effect flags mismatch: %#v", decoded.SideEffectFlags)
}
}
func TestISphereSendFileRejectsPathOutsideAllowedDir(t *testing.T) {
allowedDir := t.TempDir()
outsideDir := t.TempDir()
filePath := filepath.Join(outsideDir, "outside.txt")
if err := os.WriteFile(filePath, []byte("outside"), 0o600); err != nil {
t.Fatalf("write outside fixture file: %v", err)
}
t.Setenv(EnvSendFileAllowedDir, allowedDir)
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendFileTool(server)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendFile,
Arguments: map[string]any{
"target_type": "group",
"target_id": "project-room@conference.imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": sha256HexForSendMessageTest("outside"),
"idempotency_key": "file-outside-idem-1",
"execution_mode": "preview",
"preview": true,
},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSendFile, err)
}
if callResult.IsError {
t.Fatalf("outside path should be structured blocked metadata, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
TargetRef string `json:"target_ref"`
SideEffectFlags map[string]any `json:"side_effect_flags"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode outside-path payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" {
t.Fatalf("outside path should be blocked: %s", payload)
}
if !strings.Contains(decoded.BlockedReason, EnvSendFileAllowedDir) {
t.Fatalf("blocked reason should mention allowed-dir env: %q", decoded.BlockedReason)
}
if decoded.TargetRef != "group:project-room@conference.imopenfire1-lanzhou" {
t.Fatalf("target_ref = %q, want group ref", decoded.TargetRef)
}
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["typed_text"] != false {
t.Fatalf("blocked path should have no side effects: %#v", decoded.SideEffectFlags)
}
}
func TestISphereSendFileDuplicateIdempotencyDetected(t *testing.T) {
allowedDir := t.TempDir()
filePath := writeSendFileFixture(t, allowedDir, "duplicate.txt", "duplicate")
t.Setenv(EnvSendFileAllowedDir, allowedDir)
t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl"))
t.Setenv(EnvSendFileAuditPath, filepath.Join(t.TempDir(), "send-file-audit.jsonl"))
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendFileTool(server)
})
defer cleanup()
args := map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": sha256HexForSendMessageTest("duplicate"),
"idempotency_key": "file-duplicate-idem-1",
"execution_mode": "preview",
"preview": true,
}
if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: args}); err != nil {
t.Fatalf("first send-file preview: %v", err)
}
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: args})
if err != nil {
t.Fatalf("second send-file preview: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
RealSendAttempted bool `json:"real_send_attempted"`
Idempotency map[string]any `json:"idempotency"`
SideEffectFlags map[string]any `json:"side_effect_flags"`
}
payload, _ := json.Marshal(second.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode duplicate payload %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "planned" || decoded.RealSendAttempted {
t.Fatalf("duplicate should remain planned preview/no-send: %s", payload)
}
if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false {
t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency)
}
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false {
t.Fatalf("duplicate should have no file side effects: %#v", decoded.SideEffectFlags)
}
}
func TestISphereSendFileIdempotencyConflictBlocked(t *testing.T) {
allowedDir := t.TempDir()
firstPath := writeSendFileFixture(t, allowedDir, "first.txt", "first")
secondPath := writeSendFileFixture(t, allowedDir, "second.txt", "second")
t.Setenv(EnvSendFileAllowedDir, allowedDir)
t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl"))
t.Setenv(EnvSendFileAuditPath, filepath.Join(t.TempDir(), "send-file-audit.jsonl"))
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendFileTool(server)
})
defer cleanup()
sharedKey := "file-conflict-idem-1"
_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"file_path": firstPath,
"file_sha256": sha256HexForSendMessageTest("first"),
"idempotency_key": sharedKey,
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("first send-file preview: %v", err)
}
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"file_path": secondPath,
"file_sha256": sha256HexForSendMessageTest("second"),
"idempotency_key": sharedKey,
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("conflict send-file preview: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
RealSendAttempted bool `json:"real_send_attempted"`
Idempotency map[string]any `json:"idempotency"`
SideEffectFlags map[string]any `json:"side_effect_flags"`
}
payload, _ := json.Marshal(second.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode conflict payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" || decoded.RealSendAttempted {
t.Fatalf("conflict should be blocked/no-send: %s", payload)
}
if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true {
t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency)
}
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["typed_text"] != false {
t.Fatalf("conflict should have no side effects: %#v", decoded.SideEffectFlags)
}
}
func TestISphereSendFileAuditRedactionIsStable(t *testing.T) {
allowedDir := t.TempDir()
filePath := writeSendFileFixture(t, allowedDir, "secret.txt", "secret file bytes")
auditPath := filepath.Join(t.TempDir(), "send-file-audit.jsonl")
t.Setenv(EnvSendFileAllowedDir, allowedDir)
t.Setenv(EnvSendFileAuditPath, auditPath)
t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl"))
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendFileTool(server)
})
defer cleanup()
fileHash := sha256HexForSendMessageTest("secret file bytes")
idempotencyKey := "raw-file-idempotency-key-must-not-be-stored"
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": fileHash,
"idempotency_key": idempotencyKey,
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("send-file preview: %v", err)
}
if callResult.IsError {
t.Fatalf("send-file preview should not be tool error: %+v", callResult)
}
payload, err := os.ReadFile(auditPath)
if err != nil {
t.Fatalf("read send-file audit: %v", err)
}
audit := string(payload)
for _, forbidden := range []string{filePath, idempotencyKey, "secret file bytes"} {
if strings.Contains(audit, forbidden) {
t.Fatalf("send-file audit leaked forbidden value %q: %s", forbidden, audit)
}
}
if !strings.Contains(audit, fileHash) {
t.Fatalf("send-file audit missing file hash: %s", audit)
}
}
func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) {
allowedDir := t.TempDir()
filePath := filepath.Join(allowedDir, "blocked.txt")
if err := os.WriteFile(filePath, []byte("blocked"), 0o600); err != nil {
t.Fatalf("write blocked fixture file: %v", err)
}
t.Setenv(EnvSendFileAllowedDir, allowedDir)
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendFileTool(server)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendFile,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"file_path": filePath,
"file_sha256": sha256HexForSendMessageTest("blocked"),
"idempotency_key": "file-production-idem-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call %s production: %v", ToolNameSendFile, err)
}
if callResult.IsError {
t.Fatalf("production should be structured blocked metadata, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
ProductionSendEnabled bool `json:"production_send_enabled"`
RealSendAttempted bool `json:"real_send_attempted"`
SideEffectFlags map[string]any `json:"side_effect_flags"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode production-blocked payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
t.Fatalf("production should be blocked with reason: %s", payload)
}
if decoded.ProductionSendEnabled || decoded.RealSendAttempted {
t.Fatalf("production must not be enabled or attempted: %s", payload)
}
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["captured_network"] != false {
t.Fatalf("production blocked should have no side effects: %#v", decoded.SideEffectFlags)
}
}
func writeSendFileFixture(t *testing.T, dir string, name string, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write send-file fixture %s: %v", name, err)
}
return path
}

View File

@@ -0,0 +1,595 @@
package tools
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
ToolNameSendMessage = "isphere_send_message"
EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH"
EnvSendMessageIdempotencyPath = "ISPHERE_SEND_IDEMPOTENCY_PATH"
sendMessageConnector = "implatform-sidecar"
sendMessageConnectorMode = "sandbox-only-shell"
sendMessagePreviewStage = "preview"
sendMessageBlockedStage = "blocked"
sendMessageProductionMode = "production"
)
type SendMessageArgs struct {
TargetType string `json:"target_type" jsonschema:"target type; direct or group"`
TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"`
ContentText string `json:"content_text" jsonschema:"message body to hash and preview; not stored in audit"`
ContentSHA256 string `json:"content_sha256" jsonschema:"lowercase SHA256 hex of content_text"`
IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; stored only as SHA256 in audit"`
ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview or production; production is blocked until sandbox-send gate passes"`
}
type SendMessageAuditSink interface {
AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error
}
type SendMessageIdempotencyStore interface {
ReserveSendMessageIdempotency(ctx context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error)
}
type SendMessageAuditEvent struct {
Tool string `json:"tool"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetRef string `json:"target_ref"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorMode string `json:"connector_mode"`
ConnectorStage string `json:"connector_stage"`
ContentSHA256 string `json:"content_sha256"`
ContentLength int `json:"content_length"`
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
SendStatus string `json:"send_status"`
Result string `json:"result"`
AckRef string `json:"ack_ref,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
GateReasonCode string `json:"gate_reason_code,omitempty"`
GateReasonMessage string `json:"gate_reason_message,omitempty"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
SideEffects map[string]any `json:"side_effects"`
ContentText string `json:"-"`
IdempotencyKey string `json:"-"`
}
type SendMessageIdempotencyRecord struct {
Tool string `json:"tool"`
TargetRef string `json:"target_ref"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorStage string `json:"connector_stage"`
ContentSHA256 string `json:"content_sha256"`
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
}
type SendMessageIdempotencyDecision struct {
Duplicate bool
Conflict bool
FirstAuditRef string
FirstStartedAt string
}
type fileSendMessageAuditSink struct {
path string
}
type fileSendMessageIdempotencyStore struct {
path string
}
var sendMessageIdempotencyFileMu sync.Mutex
func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) {
RegisterISphereSendMessageToolWithState(server, auditSink, nil)
}
func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore) {
if auditSink == nil {
auditSink = defaultSendMessageAuditSink()
}
if idempotencyStore == nil {
idempotencyStore = defaultSendMessageIdempotencyStore()
}
RegisterISphereSendMessageToolWithStateAndConnector(server, auditSink, idempotencyStore, nil)
}
func RegisterISphereSendMessageToolWithStateAndConnector(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore, connector SendMessageConnector) {
if auditSink == nil {
auditSink = defaultSendMessageAuditSink()
}
if idempotencyStore == nil {
idempotencyStore = defaultSendMessageIdempotencyStore()
}
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameSendMessage,
Description: "Preview iSphere message sends with idempotency/audit metadata; production send is blocked until sandbox evidence passes.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendMessageArgs) (*mcp.CallToolResult, map[string]any, error) {
started := time.Now().UTC()
normalized, err := normalizeSendMessageArgs(input)
if err != nil {
return nil, nil, err
}
finished := time.Now().UTC()
gatePolicy := currentSendMessageGatePolicy(connector != nil)
response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil, gatePolicy)
decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event))
if err != nil {
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err)
}
applySendMessageIdempotencyDecision(response, &event, decision)
if normalized.ExecutionMode == sendMessageProductionMode && connector != nil && !decision.Duplicate && !decision.Conflict {
result, connectorErr := connector.ExecuteSendMessage(ctx, sendMessageConnectorRequestFromNormalized(normalized))
applySendMessageConnectorResult(response, &event, result, connectorErr)
}
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
}
return nil, response, nil
})
}
func defaultSendMessageAuditSink() SendMessageAuditSink {
if path := strings.TrimSpace(os.Getenv(EnvSendMessageAuditPath)); path != "" {
return fileSendMessageAuditSink{path: path}
}
return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")}
}
func defaultSendMessageIdempotencyStore() SendMessageIdempotencyStore {
if path := strings.TrimSpace(os.Getenv(EnvSendMessageIdempotencyPath)); path != "" {
return fileSendMessageIdempotencyStore{path: path}
}
return fileSendMessageIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-message-idempotency.jsonl")}
}
func currentSendMessageGatePolicy(connectorConfigured bool) SendMessageGatePolicy {
return LoadSendMessageGatePolicy(map[string]string{
EnvSendMessageProductionEnabled: os.Getenv(EnvSendMessageProductionEnabled),
}, false, connectorConfigured)
}
func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
if strings.TrimSpace(s.path) == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
payload, err := json.Marshal(event)
if err != nil {
return err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(append(payload, '\n')); err != nil {
return err
}
return nil
}
func (s fileSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) {
if strings.TrimSpace(s.path) == "" {
return SendMessageIdempotencyDecision{}, nil
}
if record.IdempotencyKeySHA256 == "" {
return SendMessageIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required")
}
sendMessageIdempotencyFileMu.Lock()
defer sendMessageIdempotencyFileMu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return SendMessageIdempotencyDecision{}, err
}
existing, err := readSendMessageIdempotencyRecords(s.path)
if err != nil {
return SendMessageIdempotencyDecision{}, err
}
for _, candidate := range existing {
if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
continue
}
decision := SendMessageIdempotencyDecision{
FirstAuditRef: candidate.AuditRef,
FirstStartedAt: candidate.StartedAt,
}
if candidate.TargetRef == record.TargetRef && candidate.ContentSHA256 == record.ContentSHA256 {
decision.Duplicate = true
return decision, nil
}
decision.Conflict = true
return decision, nil
}
payload, err := json.Marshal(record)
if err != nil {
return SendMessageIdempotencyDecision{}, err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return SendMessageIdempotencyDecision{}, err
}
defer file.Close()
if _, err := file.Write(append(payload, '\n')); err != nil {
return SendMessageIdempotencyDecision{}, err
}
return SendMessageIdempotencyDecision{}, nil
}
func readSendMessageIdempotencyRecords(path string) ([]SendMessageIdempotencyRecord, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
var records []SendMessageIdempotencyRecord
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var record SendMessageIdempotencyRecord
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, fmt.Errorf("decode idempotency record: %w", err)
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return records, nil
}
type normalizedSendMessageArgs struct {
TargetType string
TargetID string
TargetRef string
ContentText string
ContentLength int
ContentSHA256 string
IdempotencyKeySHA256 string
ExecutionMode string
}
func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs, error) {
targetType, targetRefPrefix, err := normalizeSendMessageTargetType(input.TargetType)
if err != nil {
return normalizedSendMessageArgs{}, err
}
targetID := strings.TrimSpace(input.TargetID)
if targetID == "" {
return normalizedSendMessageArgs{}, fmt.Errorf("%s target_id is required", ToolNameSendMessage)
}
contentText := input.ContentText
if strings.TrimSpace(contentText) == "" {
return normalizedSendMessageArgs{}, fmt.Errorf("%s content_text is required", ToolNameSendMessage)
}
contentHash, err := validateSendMessageContentHash(contentText, input.ContentSHA256)
if err != nil {
return normalizedSendMessageArgs{}, err
}
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
if idempotencyKey == "" {
return normalizedSendMessageArgs{}, fmt.Errorf("%s idempotency_key is required", ToolNameSendMessage)
}
mode, err := normalizeSendMessageExecutionMode(input.ExecutionMode)
if err != nil {
return normalizedSendMessageArgs{}, err
}
return normalizedSendMessageArgs{
TargetType: targetType,
TargetID: targetID,
TargetRef: targetRefPrefix + ":" + targetID,
ContentText: contentText,
ContentLength: len(contentText),
ContentSHA256: contentHash,
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
ExecutionMode: mode,
}, nil
}
func normalizeSendMessageTargetType(value string) (string, string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "direct", "contact":
return "direct", "contact", nil
case "group", "groupchat":
return "group", "group", nil
default:
return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendMessage, value)
}
}
func validateSendMessageContentHash(contentText string, provided string) (string, error) {
actual := sha256Hex(contentText)
normalized := strings.ToLower(strings.TrimSpace(provided))
if normalized == "" {
return "", fmt.Errorf("%s content_sha256 is required", ToolNameSendMessage)
}
if len(normalized) != 64 || !isLowerHex(normalized) {
return "", fmt.Errorf("%s content_sha256 must be a lowercase SHA256 hex value", ToolNameSendMessage)
}
if normalized != actual {
return "", fmt.Errorf("%s content_sha256 does not match content_text", ToolNameSendMessage)
}
return actual, nil
}
func normalizeSendMessageExecutionMode(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "preview", "dry_run", "dry-run":
return "preview", nil
case sendMessageProductionMode:
return sendMessageProductionMode, nil
default:
return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendMessage, value)
}
}
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool, gatePolicy SendMessageGatePolicy) (map[string]any, SendMessageAuditEvent) {
sideEffects := sendMessageNoSideEffects()
status := "planned"
stage := sendMessagePreviewStage
ok := true
blockedReason := any(nil)
gateReasonCode := ""
gateReasonMessage := ""
if input.ExecutionMode == sendMessageProductionMode {
if connectorAvailable {
stage = sendMessagePreviewStage
} else {
ok = false
status = "blocked"
stage = sendMessageBlockedStage
gateReasonCode = gatePolicy.ReasonCode
gateReasonMessage = gatePolicy.ReasonMessage
blockedReason = gateReasonMessage
}
}
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
event := SendMessageAuditEvent{
Tool: ToolNameSendMessage,
TargetType: input.TargetType,
TargetID: input.TargetID,
TargetRef: input.TargetRef,
ExecutionMode: input.ExecutionMode,
Connector: sendMessageConnector,
ConnectorMode: sendMessageConnectorMode,
ConnectorStage: stage,
ContentSHA256: input.ContentSHA256,
ContentLength: input.ContentLength,
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
SendStatus: status,
Result: status,
GateReasonCode: gateReasonCode,
GateReasonMessage: gateReasonMessage,
AuditRef: auditRef,
StartedAt: started.Format(time.RFC3339Nano),
FinishedAt: finished.Format(time.RFC3339Nano),
SideEffects: sideEffects,
}
response := map[string]any{
"ok": ok,
"send_status": status,
"blocked_reason": blockedReason,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"connector_stage": stage,
"gate_reason_code": gateReasonCode,
"gate_reason_message": gateReasonMessage,
"production_send_enabled": false,
"target": map[string]any{
"target_type": input.TargetType,
"target_id": input.TargetID,
"target_ref": input.TargetRef,
},
"content": map[string]any{
"content_sha256": input.ContentSHA256,
"content_length": input.ContentLength,
},
"idempotency": map[string]any{
"idempotency_key_sha256": input.IdempotencyKeySHA256,
"duplicate_detected": false,
"conflict_detected": false,
"first_audit_ref": nil,
"first_started_at": nil,
},
"side_effects": sideEffects,
"audit": map[string]any{
"tool": ToolNameSendMessage,
"source": sendMessageConnector,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"connector_stage": stage,
"gate_reason_code": gateReasonCode,
"gate_reason_message": gateReasonMessage,
"content_sha256": input.ContentSHA256,
"target_ref": input.TargetRef,
"audit_ref": auditRef,
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": status,
},
}
return response, event
}
func applySendMessageConnectorResult(response map[string]any, event *SendMessageAuditEvent, result SendMessageConnectorResult, err error) {
normalized := normalizeSendMessageConnectorResult(result, err)
response["ok"] = normalized.Accepted
response["send_status"] = normalized.Status
response["connector_mode"] = normalized.ConnectorMode
response["connector_stage"] = normalized.Status
if normalized.ProductionEnabled {
response["production_send_enabled"] = true
}
if len(normalized.SideEffects) > 0 {
response["side_effects"] = normalized.SideEffects
}
if normalized.Accepted {
response["blocked_reason"] = nil
} else if normalized.ErrorMessage != "" {
response["blocked_reason"] = normalized.ErrorMessage
}
if normalized.AckRef != "" {
response["ack_ref"] = normalized.AckRef
}
if normalized.ErrorCode != "" {
response["error_code"] = normalized.ErrorCode
}
if normalized.ErrorMessage != "" {
response["error_message"] = normalized.ErrorMessage
}
audit, _ := response["audit"].(map[string]any)
if audit != nil {
audit["result"] = normalized.Status
audit["connector_mode"] = normalized.ConnectorMode
audit["connector_stage"] = normalized.Status
if normalized.AckRef != "" {
audit["ack_ref"] = normalized.AckRef
}
if normalized.ProductionEnabled {
audit["production_send_enabled"] = true
}
if normalized.ErrorCode != "" {
audit["error_code"] = normalized.ErrorCode
}
if normalized.ErrorMessage != "" {
audit["error_message"] = normalized.ErrorMessage
}
}
if event != nil {
event.ConnectorMode = normalized.ConnectorMode
event.ConnectorStage = normalized.Status
event.SendStatus = normalized.Status
event.Result = normalized.Status
event.AckRef = normalized.AckRef
event.ErrorCode = normalized.ErrorCode
event.ErrorMessage = normalized.ErrorMessage
if len(normalized.SideEffects) > 0 {
event.SideEffects = normalized.SideEffects
}
}
}
func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord {
return SendMessageIdempotencyRecord{
Tool: event.Tool,
TargetRef: event.TargetRef,
ExecutionMode: event.ExecutionMode,
Connector: event.Connector,
ConnectorStage: event.ConnectorStage,
ContentSHA256: event.ContentSHA256,
IdempotencyKeySHA256: event.IdempotencyKeySHA256,
AuditRef: event.AuditRef,
StartedAt: event.StartedAt,
}
}
func applySendMessageIdempotencyDecision(response map[string]any, event *SendMessageAuditEvent, decision SendMessageIdempotencyDecision) {
idempotency, _ := response["idempotency"].(map[string]any)
if idempotency == nil {
idempotency = map[string]any{}
response["idempotency"] = idempotency
}
idempotency["duplicate_detected"] = decision.Duplicate
idempotency["conflict_detected"] = decision.Conflict
if decision.FirstAuditRef != "" {
idempotency["first_audit_ref"] = decision.FirstAuditRef
}
if decision.FirstStartedAt != "" {
idempotency["first_started_at"] = decision.FirstStartedAt
}
if !decision.Conflict {
return
}
blockedReason := "idempotency key was already used for a different target or content"
response["ok"] = false
response["send_status"] = "blocked"
response["blocked_reason"] = blockedReason
response["connector_stage"] = sendMessageBlockedStage
audit, _ := response["audit"].(map[string]any)
if audit != nil {
audit["result"] = "blocked"
audit["connector_stage"] = sendMessageBlockedStage
audit["blocked_reason"] = blockedReason
}
if event != nil {
event.ConnectorStage = sendMessageBlockedStage
event.SendStatus = "blocked"
event.Result = "blocked"
}
}
func sendMessageNoSideEffects() map[string]any {
return map[string]any{
"sent_message": false,
"sent_file": false,
"uploaded_file": false,
"clicked_ui": false,
"typed_text": false,
"captured_network": false,
"attached_hook": false,
"modified_client_data": false,
}
}
func sendMessageAuditRef(contentSHA256 string, idempotencySHA256 string) string {
return "send-message:" + shortHash(contentSHA256) + ":" + shortHash(idempotencySHA256)
}
func shortHash(value string) string {
if len(value) < 12 {
return value
}
return value[:12]
}
func sha256Hex(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func isLowerHex(value string) bool {
for _, ch := range value {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
return false
}
}
return true
}

View File

@@ -0,0 +1,722 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
content := "hello preview"
contentHash := sha256HexForSendMessageTest(content)
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "idem-preview-1",
"execution_mode": "preview",
},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorStage string `json:"connector_stage"`
Target map[string]any `json:"target"`
Content map[string]any `json:"content"`
SideEffects map[string]any `json:"side_effects"`
Audit map[string]any `json:"audit"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "planned" || decoded.ExecutionMode != "preview" {
t.Fatalf("unexpected preview status: %s", payload)
}
if decoded.Connector != "implatform-sidecar" || decoded.ConnectorStage != "preview" {
t.Fatalf("unexpected connector fields: %s", payload)
}
if decoded.Target["target_type"] != "direct" || decoded.Target["target_id"] != "alice@imopenfire1-lanzhou" || decoded.Target["target_ref"] != "contact:alice@imopenfire1-lanzhou" {
t.Fatalf("unexpected target: %#v", decoded.Target)
}
if decoded.Content["content_sha256"] != contentHash || decoded.Content["content_length"] != float64(len(content)) {
t.Fatalf("unexpected content metadata: %#v", decoded.Content)
}
if _, hasRawBody := decoded.Content["content_text"]; hasRawBody {
t.Fatalf("preview response leaked raw content body: %s", payload)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
}
if decoded.Audit["result"] != "planned" || decoded.Audit["tool"] != ToolNameSendMessage {
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
}
if len(audit.events) != 1 {
t.Fatalf("audit events = %+v, want one", audit.events)
}
event := audit.events[0]
if event.ContentSHA256 != contentHash || event.TargetRef != "contact:alice@imopenfire1-lanzhou" || event.Result != "planned" {
t.Fatalf("unexpected audit event: %+v", event)
}
if event.ContentText != "" || event.IdempotencyKey != "" {
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
}
}
func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
contentHash := sha256HexForSendMessageTest("blocked send")
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "group",
"target_id": "project-room@conference.imopenfire1-lanzhou",
"content_text": "blocked send",
"content_sha256": contentHash,
"idempotency_key": "idem-production-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call %s production: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("production blocked result should be structured, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
Target map[string]any `json:"target"`
SideEffects map[string]any `json:"side_effects"`
Audit map[string]any `json:"audit"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
t.Fatalf("production was not clearly blocked: %s", payload)
}
if decoded.Target["target_ref"] != "group:project-room@conference.imopenfire1-lanzhou" {
t.Fatalf("unexpected target: %#v", decoded.Target)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["uploaded_file"] != false || decoded.SideEffects["typed_text"] != false {
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
}
if decoded.Audit["result"] != "blocked" {
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
}
if len(audit.events) != 1 || audit.events[0].Result != "blocked" {
t.Fatalf("audit events = %+v, want one blocked event", audit.events)
}
}
func TestISphereSendMessageProductionBlockedIncludesGateReason(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, newFakeSendMessageIdempotencyStore())
})
defer cleanup()
content := "blocked gate reason"
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": sha256HexForSendMessageTest(content),
"idempotency_key": "idem-gate-reason-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call production blocked gate reason: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
GateReasonCode string `json:"gate_reason_code"`
GateReasonMessage string `json:"gate_reason_message"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.GateReasonCode != "send_disabled_by_default" || decoded.GateReasonMessage == "" {
t.Fatalf("production blocked response missing gate reason: %s", payload)
}
if len(audit.events) != 1 || audit.events[0].GateReasonCode != "send_disabled_by_default" || audit.events[0].GateReasonMessage == "" {
t.Fatalf("audit event missing gate reason: %+v", audit.events)
}
}
func TestISphereSendMessageProductionGateBlockedWithoutStrictEvidence(t *testing.T) {
t.Setenv(EnvSendMessageProductionEnabled, "1")
audit := &fakeSendMessageAuditSink{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, newFakeSendMessageIdempotencyStore())
})
defer cleanup()
content := "blocked without strict evidence"
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": sha256HexForSendMessageTest(content),
"idempotency_key": "idem-no-strict-evidence-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call production without strict evidence: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
GateReasonCode string `json:"gate_reason_code"`
ProductionSendEnabled bool `json:"production_send_enabled"`
SideEffects map[string]any `json:"side_effects"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.GateReasonCode != "send_evidence_missing" {
t.Fatalf("production should remain blocked on missing strict evidence: %s", payload)
}
if decoded.ProductionSendEnabled {
t.Fatalf("production_send_enabled should remain false: %s", payload)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
t.Fatalf("blocked production should have no real side effects: %#v", decoded.SideEffects)
}
}
func TestISphereSendMessageProductionRequiresEvidenceAndConnector(t *testing.T) {
t.Skip("strict v2 send evidence not present in this repository state")
}
func TestISphereSendMessageFakeConnectorAcceptedAudit(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
connector := &fakeSendMessageConnector{
result: SendMessageConnectorResult{
Accepted: true,
Status: "accepted",
AckRef: "fake-ack-1",
ConnectorMode: "fake",
},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
})
defer cleanup()
content := "fake connector accepted"
contentHash := sha256HexForSendMessageTest(content)
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "idem-fake-accepted-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call %s fake connector accepted: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("fake connector accepted result should be structured, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
ConnectorMode string `json:"connector_mode"`
ProductionSendEnabled bool `json:"production_send_enabled"`
SideEffects map[string]any `json:"side_effects"`
Audit map[string]any `json:"audit"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode fake accepted payload %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "accepted" || decoded.ConnectorMode != "fake" {
t.Fatalf("unexpected fake accepted response: %s", payload)
}
if decoded.ProductionSendEnabled {
t.Fatalf("fake connector must not flip default production_send_enabled: %s", payload)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
t.Fatalf("fake connector should not report real side effects: %#v", decoded.SideEffects)
}
if decoded.Audit["ack_ref"] != "fake-ack-1" || decoded.Audit["result"] != "accepted" {
t.Fatalf("unexpected fake accepted audit: %#v", decoded.Audit)
}
if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-accepted-1") {
t.Fatalf("fake accepted payload leaked raw content or idempotency key: %s", payload)
}
if connector.calls != 1 {
t.Fatalf("fake connector calls = %d, want 1", connector.calls)
}
if len(audit.events) != 1 {
t.Fatalf("audit events = %+v, want one", audit.events)
}
event := audit.events[0]
if event.Result != "accepted" || event.AckRef != "fake-ack-1" || event.ConnectorMode != "fake" {
t.Fatalf("unexpected audit event: %+v", event)
}
if event.ContentText != "" || event.IdempotencyKey != "" {
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
}
}
func TestISphereSendMessageFakeConnectorFailureAudit(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
connector := &fakeSendMessageConnector{
result: SendMessageConnectorResult{
Accepted: false,
Status: "failed",
ErrorCode: "fake_rejected",
ErrorMessage: "fake connector rejected the request",
ConnectorMode: "fake",
},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
})
defer cleanup()
content := "fake connector failed"
contentHash := sha256HexForSendMessageTest(content)
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "group",
"target_id": "project-room@conference.imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "idem-fake-failed-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call %s fake connector failure: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("fake connector failure should be structured, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
ErrorCode string `json:"error_code"`
SideEffects map[string]any `json:"side_effects"`
Audit map[string]any `json:"audit"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode fake failure payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "failed" || decoded.ErrorCode != "fake_rejected" {
t.Fatalf("unexpected fake failure response: %s", payload)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false {
t.Fatalf("fake failure should not report real side effects: %#v", decoded.SideEffects)
}
if decoded.Audit["error_code"] != "fake_rejected" || decoded.Audit["result"] != "failed" {
t.Fatalf("unexpected fake failure audit: %#v", decoded.Audit)
}
if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-failed-1") {
t.Fatalf("fake failure payload leaked raw content or idempotency key: %s", payload)
}
if len(audit.events) != 1 {
t.Fatalf("audit events = %+v, want one", audit.events)
}
event := audit.events[0]
if event.Result != "failed" || event.ErrorCode != "fake_rejected" || event.ConnectorMode != "fake" {
t.Fatalf("unexpected audit event: %+v", event)
}
}
func TestISphereSendMessageConnectorReceivesContentTextWithoutLeakingIt(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
connector := &fakeSendMessageConnector{
result: SendMessageConnectorResult{
Accepted: true,
Status: "accepted",
AckRef: "fake-content-ack",
ConnectorMode: "fake",
},
}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
})
defer cleanup()
content := "connector needs the raw text to send"
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": sha256HexForSendMessageTest(content),
"idempotency_key": "idem-connector-content-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
}
payload, _ := json.Marshal(callResult.StructuredContent)
if connector.lastRequest.ContentText != content {
t.Fatalf("connector ContentText = %q, want raw content", connector.lastRequest.ContentText)
}
if strings.Contains(string(payload), content) {
t.Fatalf("structured response leaked raw content: %s", payload)
}
if len(audit.events) != 1 {
t.Fatalf("audit events = %+v, want one", audit.events)
}
if audit.events[0].ContentText != "" || strings.Contains(mustMarshalStringForSendMessageTest(audit.events[0]), content) {
t.Fatalf("audit leaked raw content: %+v", audit.events[0])
}
}
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": "hash mismatch",
"content_sha256": sha256HexForSendMessageTest("different body"),
"idempotency_key": "idem-hash-mismatch",
"execution_mode": "preview",
},
})
if err == nil && !callResult.IsError {
t.Fatalf("hash mismatch was accepted")
}
}
func TestISphereSendMessageAuditRedactionIsStable(t *testing.T) {
auditPath := t.TempDir() + "\\send-audit.jsonl"
audit := fileSendMessageAuditSink{path: auditPath}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, newFakeSendMessageIdempotencyStore())
})
defer cleanup()
content := "raw body must not be stored in audit"
idempotencyKey := "raw-idempotency-key-must-not-be-stored"
contentHash := sha256HexForSendMessageTest(content)
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": idempotencyKey,
"execution_mode": "preview",
},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("preview should not return error: %+v", callResult)
}
payload, err := os.ReadFile(auditPath)
if err != nil {
t.Fatalf("read audit file: %v", err)
}
if strings.Contains(string(payload), content) || strings.Contains(string(payload), idempotencyKey) {
t.Fatalf("audit file leaked raw content or idempotency key: %s", payload)
}
if !strings.Contains(string(payload), contentHash) {
t.Fatalf("audit file missing content hash: %s", payload)
}
}
func TestISphereSendMessageDetectsDuplicateIdempotencyKey(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
content := "duplicate preview"
contentHash := sha256HexForSendMessageTest(content)
args := map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "idem-duplicate-1",
"execution_mode": "preview",
}
if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args}); err != nil {
t.Fatalf("first call: %v", err)
}
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args})
if err != nil {
t.Fatalf("second call: %v", err)
}
if second.IsError {
t.Fatalf("duplicate should be structured preview metadata, got error: %+v", second)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
Idempotency map[string]any `json:"idempotency"`
SideEffects map[string]any `json:"side_effects"`
}
payload, _ := json.Marshal(second.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode duplicate payload %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "planned" {
t.Fatalf("duplicate preview should remain planned/no-send: %s", payload)
}
if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false {
t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency)
}
if decoded.Idempotency["first_audit_ref"] == "" {
t.Fatalf("duplicate response missing first_audit_ref: %#v", decoded.Idempotency)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false {
t.Fatalf("duplicate preview should not have side effects: %#v", decoded.SideEffects)
}
if len(audit.events) != 2 || audit.events[1].Result != "planned" {
t.Fatalf("audit events = %+v, want two planned events", audit.events)
}
}
func TestISphereSendMessageBlocksIdempotencyKeyConflict(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
firstContent := "first body"
firstHash := sha256HexForSendMessageTest(firstContent)
_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": firstContent,
"content_sha256": firstHash,
"idempotency_key": "idem-conflict-1",
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("first call: %v", err)
}
secondContent := "different body"
secondHash := sha256HexForSendMessageTest(secondContent)
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": secondContent,
"content_sha256": secondHash,
"idempotency_key": "idem-conflict-1",
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("conflict call: %v", err)
}
if second.IsError {
t.Fatalf("idempotency conflict should be structured blocked metadata, got error: %+v", second)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
ConnectorStage string `json:"connector_stage"`
Idempotency map[string]any `json:"idempotency"`
SideEffects map[string]any `json:"side_effects"`
}
payload, _ := json.Marshal(second.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode conflict payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.ConnectorStage != "blocked" {
t.Fatalf("conflict should be blocked: %s", payload)
}
if !strings.Contains(strings.ToLower(decoded.BlockedReason), "idempotency") {
t.Fatalf("blocked reason should explain idempotency conflict: %q", decoded.BlockedReason)
}
if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true {
t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false {
t.Fatalf("conflict should not have side effects: %#v", decoded.SideEffects)
}
if len(audit.events) != 2 || audit.events[1].Result != "blocked" {
t.Fatalf("audit events = %+v, want second blocked event", audit.events)
}
}
func TestFileSendMessageIdempotencyStoreDetectsDuplicateAndConflict(t *testing.T) {
store := fileSendMessageIdempotencyStore{path: t.TempDir() + "\\send-idempotency.jsonl"}
first := SendMessageIdempotencyRecord{
Tool: ToolNameSendMessage,
TargetRef: "contact:alice@imopenfire1-lanzhou",
ContentSHA256: sha256HexForSendMessageTest("first body"),
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem-file-store"),
AuditRef: "send-message:first",
StartedAt: "2026-07-10T00:00:00Z",
}
firstDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first)
if err != nil {
t.Fatalf("reserve first: %v", err)
}
if firstDecision.Duplicate || firstDecision.Conflict {
t.Fatalf("first decision = %+v, want fresh", firstDecision)
}
duplicateDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first)
if err != nil {
t.Fatalf("reserve duplicate: %v", err)
}
if !duplicateDecision.Duplicate || duplicateDecision.Conflict || duplicateDecision.FirstAuditRef != first.AuditRef {
t.Fatalf("duplicate decision = %+v", duplicateDecision)
}
conflict := first
conflict.ContentSHA256 = sha256HexForSendMessageTest("changed body")
conflict.AuditRef = "send-message:conflict"
conflictDecision, err := store.ReserveSendMessageIdempotency(context.Background(), conflict)
if err != nil {
t.Fatalf("reserve conflict: %v", err)
}
if !conflictDecision.Conflict || conflictDecision.Duplicate {
t.Fatalf("conflict decision = %+v", conflictDecision)
}
if conflictDecision.FirstAuditRef != first.AuditRef {
t.Fatalf("conflict first audit ref = %q, want %q", conflictDecision.FirstAuditRef, first.AuditRef)
}
}
type fakeSendMessageAuditSink struct {
events []SendMessageAuditEvent
}
func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
f.events = append(f.events, event)
return nil
}
type fakeSendMessageIdempotencyStore struct {
records []SendMessageIdempotencyRecord
}
func newFakeSendMessageIdempotencyStore() *fakeSendMessageIdempotencyStore {
return &fakeSendMessageIdempotencyStore{}
}
func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) {
for _, existing := range f.records {
if existing.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
continue
}
decision := SendMessageIdempotencyDecision{
FirstAuditRef: existing.AuditRef,
FirstStartedAt: existing.StartedAt,
}
if existing.TargetRef == record.TargetRef && existing.ContentSHA256 == record.ContentSHA256 {
decision.Duplicate = true
return decision, nil
}
decision.Conflict = true
return decision, nil
}
f.records = append(f.records, record)
return SendMessageIdempotencyDecision{}, nil
}
type fakeSendMessageConnector struct {
result SendMessageConnectorResult
err error
calls int
lastRequest SendMessageConnectorRequest
}
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, request SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
f.calls++
f.lastRequest = request
return f.result, f.err
}
func sha256HexForSendMessageTest(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func mustMarshalStringForSendMessageTest(value any) string {
payload, err := json.Marshal(value)
if err != nil {
panic(err)
}
return string(payload)
}

View File

@@ -0,0 +1,81 @@
package tools
import (
"context"
"fmt"
"strings"
)
type BRouteSendAdapterConfig struct {
SidecarPath string
TimeoutSeconds int
Mode string
}
type bRouteSendMessageConnector struct {
config BRouteSendAdapterConfig
}
func NewBRouteSendMessageConnector(config BRouteSendAdapterConfig) SendMessageConnector {
config.Mode = strings.TrimSpace(strings.ToLower(config.Mode))
if config.Mode == "" {
config.Mode = "disabled"
}
return bRouteSendMessageConnector{config: config}
}
func (c bRouteSendMessageConnector) ExecuteSendMessage(_ context.Context, request SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
switch c.config.Mode {
case "disabled":
result := SendMessageConnectorResult{
Accepted: false,
Status: "blocked",
ErrorCode: "broute_mode_blocked",
ErrorMessage: "B-route send adapter is disabled",
ConnectorMode: "broute-disabled",
}
return result, fmt.Errorf(result.ErrorMessage)
case "dry_run_contract":
if err := validateBRouteSendMessageRequest(request); err != nil {
return SendMessageConnectorResult{
Accepted: false,
Status: "failed",
ErrorCode: "broute_invalid_request",
ErrorMessage: err.Error(),
ConnectorMode: "broute-dry-run-contract",
}, nil
}
return SendMessageConnectorResult{
Accepted: false,
Status: "blocked",
ErrorCode: "broute_dry_run_only",
ErrorMessage: "B-route adapter dry_run_contract mode validates request shape only and does not start a sidecar",
ConnectorMode: "broute-dry-run-contract",
}, nil
default:
result := SendMessageConnectorResult{
Accepted: false,
Status: "blocked",
ErrorCode: "broute_mode_blocked",
ErrorMessage: "B-route send adapter mode is not enabled: " + c.config.Mode,
ConnectorMode: "broute-disabled",
}
return result, fmt.Errorf(result.ErrorMessage)
}
}
func validateBRouteSendMessageRequest(request SendMessageConnectorRequest) error {
if strings.TrimSpace(request.TargetRef) == "" {
return fmt.Errorf("target_ref is required")
}
if strings.TrimSpace(request.ContentSHA256) == "" {
return fmt.Errorf("content_sha256 is required")
}
if strings.TrimSpace(request.IdempotencyKeySHA256) == "" {
return fmt.Errorf("idempotency_key_sha256 is required")
}
if request.ExecutionMode != sendMessageProductionMode {
return fmt.Errorf("execution_mode must be production")
}
return nil
}

View File

@@ -0,0 +1,55 @@
package tools
import (
"context"
"strings"
"testing"
)
func TestBRouteSendMessageConnectorDisabledMode(t *testing.T) {
connector := NewBRouteSendMessageConnector(BRouteSendAdapterConfig{Mode: "disabled"})
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
TargetType: "direct",
TargetID: "u1",
TargetRef: "contact:u1",
ContentSHA256: "abc",
ContentLength: 3,
IdempotencyKeySHA256: "idem",
ExecutionMode: "production",
})
if err == nil {
t.Fatalf("expected disabled error")
}
if result.ErrorCode != "broute_mode_blocked" {
t.Fatalf("unexpected error code: %s", result.ErrorCode)
}
if result.ConnectorMode != "broute-disabled" {
t.Fatalf("connector mode = %q, want broute-disabled", result.ConnectorMode)
}
}
func TestBRouteSendMessageConnectorDryRunContract(t *testing.T) {
connector := NewBRouteSendMessageConnector(BRouteSendAdapterConfig{
SidecarPath: `Z:\path\that\must\not\be\started\SendSidecar.exe`,
TimeoutSeconds: 5,
Mode: "dry_run_contract",
})
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
TargetType: "direct",
TargetID: "u1",
TargetRef: "contact:u1",
ContentSHA256: strings.Repeat("a", 64),
ContentLength: 3,
IdempotencyKeySHA256: strings.Repeat("b", 64),
ExecutionMode: "production",
})
if err != nil {
t.Fatalf("dry-run contract should not try to start a sidecar or return process error: %v", err)
}
if result.Accepted {
t.Fatalf("dry-run contract must not accept a real send: %+v", result)
}
if result.ConnectorMode != "broute-dry-run-contract" || result.ErrorCode != "broute_dry_run_only" {
t.Fatalf("unexpected dry-run result: %+v", result)
}
}

View File

@@ -0,0 +1,81 @@
package tools
import (
"context"
"strings"
)
type SendMessageConnector interface {
ExecuteSendMessage(context.Context, SendMessageConnectorRequest) (SendMessageConnectorResult, error)
}
type SendMessageConnectorRequest struct {
TargetType string
TargetID string
TargetRef string
ContentText string
ContentSHA256 string
ContentLength int
IdempotencyKeySHA256 string
ExecutionMode string
}
type SendMessageConnectorResult struct {
Accepted bool
Status string
AckRef string
ErrorCode string
ErrorMessage string
ConnectorMode string
ProductionEnabled bool
SideEffects map[string]any
}
func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest {
return SendMessageConnectorRequest{
TargetType: input.TargetType,
TargetID: input.TargetID,
TargetRef: input.TargetRef,
ContentText: input.ContentText,
ContentSHA256: input.ContentSHA256,
ContentLength: input.ContentLength,
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
ExecutionMode: input.ExecutionMode,
}
}
func normalizeSendMessageConnectorResult(result SendMessageConnectorResult, err error) SendMessageConnectorResult {
normalized := result
normalized.Status = strings.TrimSpace(normalized.Status)
normalized.AckRef = strings.TrimSpace(normalized.AckRef)
normalized.ErrorCode = strings.TrimSpace(normalized.ErrorCode)
normalized.ErrorMessage = strings.TrimSpace(normalized.ErrorMessage)
normalized.ConnectorMode = strings.TrimSpace(normalized.ConnectorMode)
if normalized.ConnectorMode == "" {
normalized.ConnectorMode = sendMessageConnectorMode
}
if err != nil {
normalized.Accepted = false
if normalized.Status == "" {
normalized.Status = "failed"
}
if normalized.ErrorCode == "" {
normalized.ErrorCode = "connector_error"
}
if normalized.ErrorMessage == "" {
normalized.ErrorMessage = err.Error()
}
return normalized
}
if normalized.Status == "" {
if normalized.Accepted {
normalized.Status = "accepted"
} else {
normalized.Status = "failed"
}
}
if !normalized.Accepted && normalized.ErrorCode == "" {
normalized.ErrorCode = "connector_rejected"
}
return normalized
}

Some files were not shown because too many files have changed in this diff Show More