Compare commits
112 Commits
codex/isph
...
codex/a-ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811f760efe | ||
|
|
a06fe60eb2 | ||
|
|
c2a1ca560f | ||
|
|
e8b4110cd2 | ||
|
|
d1a1e499ea | ||
|
|
8aa952a491 | ||
|
|
456ef44e8e | ||
|
|
caa7649feb | ||
|
|
d94d0a318a | ||
|
|
57db83d36c | ||
|
|
6164703a9a | ||
|
|
c8b619c254 | ||
|
|
7db022469c | ||
|
|
fab0f0f5c0 | ||
|
|
1fd10b5df7 | ||
|
|
6781c75f84 | ||
|
|
41b4b5b7d2 | ||
|
|
281b9dda2a | ||
|
|
713917d929 | ||
|
|
b08b0e01bd | ||
|
|
2a7087218b | ||
|
|
dadf42817c | ||
|
|
9eb7f382cc | ||
|
|
93112d6d99 | ||
|
|
138615a29c | ||
|
|
89d7f65b45 | ||
|
|
8cdaa3c0bc | ||
|
|
ac3f10f53c | ||
|
|
1f99681348 | ||
|
|
b2ce7b0e36 | ||
|
|
81f23a6a54 | ||
|
|
f7914a63dc | ||
|
|
349fbe4a44 | ||
|
|
5578f0bda7 | ||
|
|
ea38346ce3 | ||
|
|
6ca6c08b62 | ||
|
|
1d6dd2f504 | ||
|
|
d78152cbfa | ||
|
|
5a1b85906b | ||
|
|
3f270b454f | ||
|
|
0820717767 | ||
|
|
44d27879f6 | ||
|
|
c0287fa2e6 | ||
|
|
c8ecf5b36e | ||
|
|
1a21a01184 | ||
|
|
78f0e47547 | ||
|
|
07d127c44a | ||
|
|
298482c25d | ||
|
|
10e7a616c6 | ||
|
|
1277316663 | ||
|
|
8be4a6a6c5 | ||
|
|
974fcaa018 | ||
|
|
5b05228ba8 | ||
|
|
b442138e5c | ||
|
|
8378935c90 | ||
|
|
7ced6e45f6 | ||
|
|
3f052125ad | ||
|
|
fce1232f21 | ||
|
|
b1891dd6cd | ||
|
|
95699e05b5 | ||
|
|
aa636f5a33 | ||
|
|
e7324d8aa6 | ||
|
|
bbffa2189d | ||
|
|
87a787ee1b | ||
|
|
8f526e9aa9 | ||
|
|
bd03cf5d83 | ||
|
|
fe242145bb | ||
|
|
3d68a18097 | ||
|
|
444c0d8ff1 | ||
|
|
4502a05bb4 | ||
|
|
7e616a1f10 | ||
|
|
2832e286e7 | ||
|
|
2621d34336 | ||
|
|
878b729015 | ||
|
|
28bcb94b41 | ||
|
|
220f3f68c1 | ||
|
|
57eb7a5fc7 | ||
|
|
fe2a1947f3 | ||
|
|
4d5b314f83 | ||
|
|
de3bb922da | ||
|
|
1eed0f62f2 | ||
|
|
57b38d8244 | ||
|
|
e3de9fb22e | ||
|
|
0a7b9d8596 | ||
|
|
e993ce15fb | ||
|
|
cfc7787cdd | ||
|
|
b2d839e1bc | ||
|
|
fcd7a2ec53 | ||
|
|
a009128e50 | ||
|
|
d85fae0852 | ||
|
|
2609b2028d | ||
|
|
83585d6696 | ||
|
|
fc2fc04014 | ||
|
|
f6bc371188 | ||
|
|
e1e2ca92b0 | ||
|
|
696ee2c7ab | ||
|
|
e487aad2a4 | ||
|
|
747339ff23 | ||
| 7b30e8e76c | |||
|
|
cfe2ce5d64 | ||
|
|
d8dabada57 | ||
|
|
27c87d318d | ||
|
|
4d5a9b0ae8 | ||
|
|
d648c9476d | ||
|
|
a7b41ccb15 | ||
|
|
5b5d33140b | ||
|
|
b2b1c0c968 | ||
|
|
19d05fe3c9 | ||
|
|
55c749b0ab | ||
|
|
22e4e8f8ce | ||
|
|
08516ed35a | ||
|
|
c54f7c447a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,3 +18,9 @@ obj/
|
||||
dist/
|
||||
__pycache__/
|
||||
.venv/
|
||||
|
||||
# Local handoff prompt
|
||||
/给接手人的交接话术.md
|
||||
|
||||
# Local intake scratch directory
|
||||
/temp/
|
||||
|
||||
25
README.md
25
README.md
@@ -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.
|
||||
|
||||
673
cmd/isphere-capability-smoke/main.go
Normal file
673
cmd/isphere-capability-smoke/main.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
62
cmd/uia-selector-report/main.go
Normal file
62
cmd/uia-selector-report/main.go
Normal 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
|
||||
}
|
||||
63
cmd/uia-selector-report/main_test.go
Normal file
63
cmd/uia-selector-report/main_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
398
docs/current-status-card.md
Normal file
398
docs/current-status-card.md
Normal file
@@ -0,0 +1,398 @@
|
||||
# Current Status Card
|
||||
|
||||
Date: 2026-07-10
|
||||
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 只作为底层辅助和证据,不再作为主线继续扩展。
|
||||
|
||||
## 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 preview-only tool, 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 only; production is blocked
|
||||
- `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.4.0` adds read-only `probe_send_entrypoints` and `probe_send_uia_controls` for R6a/C31 send connector preflight.
|
||||
- `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, send-message preview/production-blocked behavior, 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.
|
||||
- `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 implemented real message sending or real file upload. R6b adds preview/dry-run only; it does not prove send invocation, success/ack, or idempotent production behavior.
|
||||
- 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: `R14 release-candidate report complete`; next step is evidence-driven production gate review.
|
||||
|
||||
Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
||||
|
||||
Previous roadmap file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.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 branch is in progress on `codex/a-route-rpa-send`:
|
||||
|
||||
- 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.
|
||||
- Default MCP smoke still clears A-route env and remains preview/blocked unless explicitly configured.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 调用层和受控发送工具。
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -26,10 +26,16 @@ N0 Baseline cleanup and direction lock
|
||||
-> N10 Operator runbook and process scripts
|
||||
-> N11 Commit/review checkpoint
|
||||
-> N12-pre Offline evidence intake
|
||||
-> N12 Real logged-in UIA capture gate
|
||||
-> N12R Internal-sandbox live UIA capture route
|
||||
-> N12R-review Selector design review gate
|
||||
|
||||
Blocked/non-active:
|
||||
-> N12-current Current-environment real logged-in UIA capture gate
|
||||
```
|
||||
|
||||
Hard rule: **no node may start until the previous node's acceptance conditions are met.**
|
||||
**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`.
|
||||
|
||||
Node order: start each node after the previous node's acceptance conditions are met.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,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:**
|
||||
|
||||
@@ -47,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:**
|
||||
|
||||
@@ -63,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`.
|
||||
@@ -151,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:**
|
||||
|
||||
@@ -171,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:**
|
||||
|
||||
@@ -363,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.
|
||||
|
||||
@@ -389,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:
|
||||
|
||||
@@ -405,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:**
|
||||
|
||||
@@ -458,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:**
|
||||
|
||||
@@ -477,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:**
|
||||
|
||||
@@ -532,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:**
|
||||
|
||||
@@ -550,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:**
|
||||
|
||||
@@ -616,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:**
|
||||
|
||||
@@ -684,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:**
|
||||
|
||||
@@ -744,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:**
|
||||
|
||||
@@ -753,7 +756,7 @@ Pass if:
|
||||
- Unrelated generated files are staged.
|
||||
- Worktree contains ignored build outputs outside `runs/`.
|
||||
|
||||
**Next node:** N12-pre if the current environment cannot log in; otherwise N12 may start only when the real logged-in current-environment preconditions below are met.
|
||||
**Next node:** N12-pre for offline evidence intake, followed by N12R for live capture in an internal-network test sandbox. `N12-current` remains blocked because the current outer-network environment cannot log in to internal iSphere.
|
||||
|
||||
---
|
||||
|
||||
@@ -810,44 +813,121 @@ 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:** N12 remains blocked until the current environment can manually log in and perform a live current-environment UIA capture.
|
||||
**Next node:** N12R internal-sandbox live capture route if live UI evidence is needed. `N12-current` remains blocked in the outer-network environment.
|
||||
|
||||
---
|
||||
|
||||
## N12: Real logged-in UIA capture gate
|
||||
## N12R: Internal-sandbox live capture route
|
||||
|
||||
**Purpose:** Capture real iSphere UIA evidence after the user provides/login context.
|
||||
**Purpose:** Capture live iSphere UIA evidence from a separate internal-network test sandbox where iSphere can be manually opened and logged in.
|
||||
|
||||
**Preconditions:**
|
||||
|
||||
- N11 accepted.
|
||||
- Optional N12-pre offline evidence, if used, has been validated only as pre-gate context and not as N12 pass evidence.
|
||||
- User manually opens and logs in to iSphere.
|
||||
- iSphere main window is visible.
|
||||
- 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 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`.
|
||||
|
||||
**Actions:**
|
||||
|
||||
Use Go MCP tools:
|
||||
- In the internal sandbox, run only the first-phase read-only operations:
|
||||
|
||||
```text
|
||||
win_helper_version
|
||||
win_helper_self_check
|
||||
win_helper_scan_windows
|
||||
win_helper_dump_uia
|
||||
```
|
||||
|
||||
or the equivalent helper ops:
|
||||
|
||||
```text
|
||||
version
|
||||
self_check
|
||||
scan_windows
|
||||
dump_uia
|
||||
```
|
||||
|
||||
- Save the returned package under:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
- Follow the package contract in `docs/internal-sandbox-live-capture-plan.md`.
|
||||
|
||||
**Expected outputs:**
|
||||
|
||||
- `metadata/source-notes.txt` stating internal-network test sandbox, manual login, main window visible, and forbidden actions not performed.
|
||||
- `helper/version.json`.
|
||||
- `helper/self-check.json`.
|
||||
- `windows/scan-windows.json`.
|
||||
- `windows/selected-window.json`.
|
||||
- `uia/dump-uia-main.json`.
|
||||
- `uia/dump-uia-main-redacted.json`.
|
||||
- `notes/operator-notes.txt`.
|
||||
|
||||
**Acceptance conditions:**
|
||||
|
||||
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 record the exact capture scope.
|
||||
- The redacted dump exists before broad sharing.
|
||||
|
||||
**Stop conditions:**
|
||||
|
||||
- Internal sandbox cannot manually log in.
|
||||
- No candidate window appears.
|
||||
- UIA tree is empty or inaccessible.
|
||||
- Package contains secrets or message contents that require redaction before review.
|
||||
- Any step expands beyond scan/dump evidence capture into business actions or low-level client manipulation.
|
||||
|
||||
**Next node:** N12R-review selector design review gate after N12R evidence is accepted and a separate selector design plan exists.
|
||||
|
||||
---
|
||||
|
||||
## N12-current: Current-environment real logged-in UIA capture gate (blocked)
|
||||
|
||||
This is the original N12 definition. It is retained as a blocked/non-active gate for historical clarity. It is not the active route while the repository environment is outside the internal network.
|
||||
|
||||
**Blocked reason:** iSphere is internal-network only, and the current repository environment cannot manually log in.
|
||||
|
||||
**Original purpose:** Capture real iSphere UIA evidence from a manually logged-in iSphere window in the same environment where this repository is running.
|
||||
|
||||
**Original preconditions, if network placement changes later:**
|
||||
|
||||
- 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 company login process.
|
||||
- iSphere main window is visible in that same environment.
|
||||
|
||||
**Original tool actions:**
|
||||
|
||||
Use only these Go MCP tools:
|
||||
|
||||
```text
|
||||
win_helper_scan_windows
|
||||
@@ -860,35 +940,9 @@ Save output under:
|
||||
runs/real-loggedin-lab/uia-dumps/
|
||||
```
|
||||
|
||||
**Expected outputs:**
|
||||
**Current decision:** Active route uses N12R internal-sandbox live capture instead of waiting on this gate.
|
||||
|
||||
- One or more real iSphere UIA dump JSON files.
|
||||
- Window metadata showing process name/title/handle.
|
||||
|
||||
**Acceptance conditions:**
|
||||
|
||||
Pass if:
|
||||
|
||||
- Candidate iSphere/IMPlatform window is found.
|
||||
- UIA root node is a window/control tree.
|
||||
- Dump includes enough fields to identify controls:
|
||||
- `name`
|
||||
- `control_type`
|
||||
- `automation_id`
|
||||
- `class_name`
|
||||
- `children`
|
||||
- No clicking, typing, sending, uploading, receiving, or downloading occurs.
|
||||
- No passwords/tokens are captured.
|
||||
|
||||
**Stop conditions:**
|
||||
|
||||
- User is not logged in.
|
||||
- No candidate window appears.
|
||||
- UIA tree is empty or inaccessible.
|
||||
- Captured data contains sensitive secrets that need redaction before reporting.
|
||||
- Current environment cannot log in; in that case, use only N12-pre offline analysis and do not mark N12 passed.
|
||||
|
||||
**Next node:** Future selector design. Do not implement selectors until N12 passes.
|
||||
**Next node:** Future selector design follows accepted N12R evidence, or a passed N12-current gate, plus a separate selector design plan.
|
||||
|
||||
---
|
||||
|
||||
@@ -896,28 +950,28 @@ Pass if:
|
||||
|
||||
Every node must satisfy these rules:
|
||||
|
||||
1. No Python MCP code.
|
||||
2. No real send/search/file action before N12.
|
||||
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 if the current environment cannot log in; N12 only after current-environment manual login is possible.
|
||||
当前下一步:MCP 核心工具合同冻结 + 只读消息源 discovery + N12-pre 证据包整理。
|
||||
```
|
||||
|
||||
Immediate next action:
|
||||
|
||||
```text
|
||||
If login is unavailable here, collect/validate the full sandbox artifact bundle described in docs/offline-evidence-intake-plan.md. Minimal manifest/scan/dump JSON is optional and only for advanced operators.
|
||||
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 true N12 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.
|
||||
|
||||
@@ -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, and two preview-only send tools 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. Send tools are `isphere_send_message` and `isphere_send_file` preview/dry-run only; production send/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,19 +374,238 @@ 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.
|
||||
## 12. If this outer-network environment cannot log in
|
||||
|
||||
## 10. If this environment cannot log in
|
||||
iSphere is internal-network only. If the current repository environment is outside that network, use two separate routes:
|
||||
|
||||
Do not run or mark the real-login UIA capture gate as passed.
|
||||
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>\`.
|
||||
|
||||
If login is only possible in another test sandbox, follow `docs/offline-evidence-intake-plan.md` and use the recommended **Full sandbox artifact bundle** route. The user does not need to generate JSON. Ask them to copy the logged-in test sandbox's install directory, user-data directories, ProgramData directories, shortcuts, `%TEMP%\importal` / `localcache` candidates, and simple copy notes into `runs\offline-evidence-intake\<evidence-id>\`.
|
||||
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:
|
||||
|
||||
The minimal `manifest.json` / `scan_windows-redacted.json` / `dump_uia-main-redacted.json` route is optional and only for advanced operators. In either route, the package is `N12-pre` context only. It can support offline inventory and reverse analysis, but it does not replace live current-environment `win_helper_scan_windows` and `win_helper_dump_uia` evidence.
|
||||
```text
|
||||
win_helper_version
|
||||
win_helper_self_check
|
||||
win_helper_scan_windows
|
||||
win_helper_dump_uia
|
||||
```
|
||||
|
||||
After receiving a full bundle, first do read-only inventory. Do not directly run unknown copied binaries, do not modify original copied files, do not use copied credential-like material to log in, and do not design automatic login, send message, send file, receive file, injection, hook, memory reading, or endpoint-security bypass behavior.
|
||||
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.
|
||||
|
||||
## 11. Troubleshooting
|
||||
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
|
||||
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
|
||||
$reportRoot = "runs\internal-sandbox-live-capture-reports"
|
||||
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
|
||||
-PackageRoot $captureRoot `
|
||||
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
|
||||
```
|
||||
|
||||
Keep the validation report outside `$captureRoot`; the returned package is evidence input and should remain unchanged.
|
||||
|
||||
## 13. R6c online sandbox send evidence package
|
||||
|
||||
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。
|
||||
- 阻断:生产发消息、真实文件下载、生产发文件。
|
||||
|
||||
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: 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"}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -379,3 +379,68 @@ Only after that should the team decide whether to:
|
||||
3. Wait until current-environment live login becomes possible.
|
||||
4. Re-plan true N12.
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 12. Reality update - 2026-07-08
|
||||
|
||||
The current repository environment is an outer-network coordination and analysis environment. iSphere / IMPlatformClient is internal-network only, so manual login cannot happen in the current environment.
|
||||
|
||||
Do not keep waiting for current-environment login as the active path. The active live-capture route is now:
|
||||
|
||||
```text
|
||||
N12R: Internal-sandbox live capture route
|
||||
```
|
||||
|
||||
New operator documents:
|
||||
|
||||
```text
|
||||
docs/internal-sandbox-live-capture-plan.md
|
||||
Defines N12R node sequence, package contract, acceptance conditions, and boundaries.
|
||||
|
||||
docs/n12r-final-handoff-checklist.md
|
||||
Short final handoff checklist for internal-sandbox work, outer-network validation, boundaries, and next decision.
|
||||
|
||||
docs/internal-sandbox-operator-runbook.md
|
||||
Step-by-step instructions for the internal-network sandbox operator.
|
||||
|
||||
docs/n12r-return-package-validation-checklist.md
|
||||
Outer-network validation checklist for returned N12R packages.
|
||||
|
||||
scripts/validate-n12r-return-package.ps1
|
||||
One-command read-only validator for returned N12R packages. It writes validation reports outside the returned package root.
|
||||
|
||||
scripts/test-validate-n12r-return-package.ps1
|
||||
Local regression tests for the N12R package validator.
|
||||
```
|
||||
|
||||
N12R package destination:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
Suggested outer-network validation command after a package returns:
|
||||
|
||||
```powershell
|
||||
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
|
||||
$reportRoot = "runs\internal-sandbox-live-capture-reports"
|
||||
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
|
||||
-PackageRoot $captureRoot `
|
||||
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
|
||||
```
|
||||
|
||||
N12-pre offline evidence remains useful for static analysis, but it does not replace N12R live UIA capture. Original current-environment N12 remains blocked unless the repository environment is moved into the internal network and can manually log in.
|
||||
|
||||
The only allowed live operations in N12R remain:
|
||||
|
||||
```text
|
||||
win_helper_version
|
||||
win_helper_self_check
|
||||
win_helper_scan_windows
|
||||
win_helper_dump_uia
|
||||
```
|
||||
|
||||
Do not implement selector/action tools until N12R evidence is accepted and a separate selector design plan is approved.
|
||||
|
||||
217
docs/internal-sandbox-live-capture-plan.md
Normal file
217
docs/internal-sandbox-live-capture-plan.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Internal Sandbox Live Capture Plan
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document replaces the unrealistic assumption that the current outer-network repository environment can log in to iSphere.
|
||||
|
||||
iSphere is an internal-network application. The current environment is an outer-network coordination and analysis environment. Therefore, live login and live UI Automation capture must happen in a separate internal-network test sandbox where the operator can manually open and log in to iSphere.
|
||||
|
||||
This path is named:
|
||||
|
||||
```text
|
||||
N12R: Internal-sandbox live capture route
|
||||
```
|
||||
|
||||
`N12R` is a practical replacement route for collecting live read-only UI evidence from the internal sandbox. It does not mean the original current-environment N12 gate has passed.
|
||||
|
||||
## 2. Environment roles
|
||||
|
||||
| Environment | Role | Allowed work |
|
||||
| --- | --- | --- |
|
||||
| Current outer-network repo environment | Coordination, planning, static/offline evidence analysis, report validation | Read docs, inspect copied evidence packages, generate reports, validate returned capture packages |
|
||||
| Internal-network test sandbox | Manual iSphere login and live read-only capture | Build/verify helper, manually log in, run only read-only scan/dump capture, package results |
|
||||
|
||||
## 3. Current node state
|
||||
|
||||
Completed before this plan:
|
||||
|
||||
```text
|
||||
N0-N11 First Go MCP / C# helper phase completed
|
||||
N12-pre Offline evidence intake and reports for copied package zyl completed
|
||||
```
|
||||
|
||||
Original true N12 remains blocked in the current environment because this environment cannot reach or log in to internal iSphere.
|
||||
|
||||
New route:
|
||||
|
||||
```text
|
||||
N12R-0 Prepare internal-sandbox runbook and capture package contract
|
||||
N12R-1 Internal sandbox verifies helper and Go MCP readiness
|
||||
N12R-2 Operator manually opens and logs in to iSphere in the internal sandbox
|
||||
N12R-3 Operator performs read-only live window scan and UIA dump
|
||||
N12R-4 Operator packages redacted live capture outputs
|
||||
N12R-5 Outer-network environment validates returned package
|
||||
N12R-6 Review gate: decide whether selector design may start
|
||||
```
|
||||
|
||||
No node may be skipped. Offline evidence never replaces live capture evidence.
|
||||
|
||||
## 4. Hard boundaries
|
||||
|
||||
The internal sandbox route only allows read-only evidence capture.
|
||||
|
||||
Allowed operations:
|
||||
|
||||
```text
|
||||
win_helper_version
|
||||
win_helper_self_check
|
||||
win_helper_scan_windows
|
||||
win_helper_dump_uia
|
||||
```
|
||||
|
||||
Equivalent C# helper operations are also allowed only for capture fallback:
|
||||
|
||||
```text
|
||||
version
|
||||
self_check
|
||||
scan_windows
|
||||
dump_uia
|
||||
```
|
||||
|
||||
Forbidden operations and designs:
|
||||
|
||||
```text
|
||||
search contacts
|
||||
open conversation
|
||||
send message
|
||||
send file
|
||||
receive/download file
|
||||
automatic login
|
||||
using copied credentials
|
||||
credential/token/cookie extraction
|
||||
process injection
|
||||
hook
|
||||
memory reading
|
||||
endpoint-security bypass/evasion
|
||||
stealth/persistence
|
||||
modifying the iSphere client
|
||||
running copied executables from offline evidence packages
|
||||
```
|
||||
|
||||
The operator must use normal manual login only.
|
||||
|
||||
## 5. N12R package contract
|
||||
|
||||
Returned live capture packages should be placed in this repository under:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
Recommended package shape:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
metadata/
|
||||
source-notes.txt
|
||||
os-version.txt
|
||||
process-list.txt
|
||||
repo-status.txt
|
||||
verification-summary.txt
|
||||
helper/
|
||||
version.json
|
||||
self-check.json
|
||||
windows/
|
||||
scan-windows.json
|
||||
selected-window.json
|
||||
uia/
|
||||
dump-uia-main.json
|
||||
dump-uia-main-redacted.json
|
||||
notes/
|
||||
operator-notes.txt
|
||||
```
|
||||
|
||||
Optional files:
|
||||
|
||||
```text
|
||||
helper/
|
||||
verify-win-helper-output.txt
|
||||
mcp/
|
||||
verify-go-mcp-output.txt
|
||||
mcp-client-config.txt
|
||||
screenshots/
|
||||
main-window-redacted.png
|
||||
```
|
||||
|
||||
Do not include passwords, tokens, cookies, private keys, full message bodies, or unrelated business documents.
|
||||
|
||||
## 6. Required package notes
|
||||
|
||||
`metadata/source-notes.txt` must include:
|
||||
|
||||
```text
|
||||
capture_id: <capture-id>
|
||||
source_environment: internal-network test sandbox
|
||||
operator: <name or role>
|
||||
capture_time_local: <YYYY-MM-DD HH:mm:ss timezone>
|
||||
iSphere manually logged in: yes
|
||||
client opened before capture: yes
|
||||
main window visible: yes
|
||||
operations used: version, self_check, scan_windows, dump_uia
|
||||
forbidden actions performed: no
|
||||
this package is N12R live evidence, not current-environment N12 pass
|
||||
```
|
||||
|
||||
`notes/operator-notes.txt` should include:
|
||||
|
||||
```text
|
||||
What client was opened: <iSphere / IMPlatformClient>
|
||||
How login was performed: manual normal company process
|
||||
Any MFA/SSO used: yes/no, no secrets included
|
||||
Selected window reason: <title/process/class/score>
|
||||
Redactions applied: <brief list>
|
||||
Any capture problems: <brief list>
|
||||
```
|
||||
|
||||
## 7. Acceptance conditions for N12R
|
||||
|
||||
N12R package validation passes only if all are true:
|
||||
|
||||
1. Package is under `runs/internal-sandbox-live-capture/<capture-id>/`.
|
||||
2. `metadata/source-notes.txt` exists and states this came from an internal-network test sandbox.
|
||||
3. Notes state iSphere was manually logged in and the main window was visible.
|
||||
4. `helper/version.json` exists and reports `ISphereWinHelper`.
|
||||
5. `helper/self-check.json` exists and indicates desktop/UIA availability.
|
||||
6. `windows/scan-windows.json` exists and includes an iSphere / IMPlatformClient candidate window.
|
||||
7. `windows/selected-window.json` records the selected candidate window metadata.
|
||||
8. `uia/dump-uia-main.json` exists and contains a UI Automation root tree.
|
||||
9. Redacted copy `uia/dump-uia-main-redacted.json` exists before broad sharing.
|
||||
10. No evidence suggests clicking, typing, sending, uploading, downloading, automatic login, injection, hook, memory reading, or bypass behavior.
|
||||
|
||||
## 8. What N12R does not authorize
|
||||
|
||||
N12R does not authorize selector implementation or action tools by itself. It only provides live UI structure evidence.
|
||||
|
||||
After N12R package validation, the next step is a review gate:
|
||||
|
||||
```text
|
||||
Review whether selector design may start.
|
||||
```
|
||||
|
||||
Selector design, if approved later, must begin as a separate written plan. It must still avoid message sending, file transfer, automatic login, injection, hook, memory reading, and bypass behavior unless an explicit later plan changes the project boundary.
|
||||
|
||||
## 9. Relationship to existing offline evidence
|
||||
|
||||
Existing package:
|
||||
|
||||
```text
|
||||
runs/offline-evidence-intake/zyl/
|
||||
```
|
||||
|
||||
Existing reports:
|
||||
|
||||
```text
|
||||
runs/offline-evidence-intake-reports/zyl/
|
||||
```
|
||||
|
||||
Those reports remain useful for:
|
||||
|
||||
```text
|
||||
application inventory
|
||||
local data discovery
|
||||
static evidence analysis
|
||||
MsgLib format hypotheses
|
||||
timeline and redaction planning
|
||||
```
|
||||
|
||||
They do not replace N12R live UIA capture. Offline evidence is context only.
|
||||
387
docs/internal-sandbox-operator-runbook.md
Normal file
387
docs/internal-sandbox-operator-runbook.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# 内网沙盒操作员运行手册
|
||||
|
||||
本手册供能够访问内网测试沙盒的操作员使用;该沙盒中可以人工打开并登录 iSphere / IMPlatformClient。
|
||||
|
||||
目标是为外网协调环境生成一个“已登录状态下的只读现场采集包”。
|
||||
|
||||
## 1. 安全边界
|
||||
|
||||
只允许执行以下动作:
|
||||
|
||||
```text
|
||||
人工打开 iSphere / IMPlatformClient
|
||||
人工正常登录
|
||||
保持主窗口可见
|
||||
运行 version
|
||||
运行 self_check
|
||||
运行 scan_windows
|
||||
运行 dump_uia
|
||||
把 JSON / 文本文档输出复制进采集包
|
||||
```
|
||||
|
||||
禁止执行以下动作:
|
||||
|
||||
```text
|
||||
自动登录
|
||||
搜索联系人
|
||||
打开会话
|
||||
发送消息
|
||||
发送文件
|
||||
接收/下载文件
|
||||
使用复制出来的凭据
|
||||
提取密码/token/cookie/私钥
|
||||
注入进程
|
||||
hook
|
||||
读取目标进程内存
|
||||
绕过终端安全/安防
|
||||
运行离线证据包中复制出来的可执行文件
|
||||
```
|
||||
|
||||
## 2. 在内网沙盒中准备仓库
|
||||
|
||||
在仓库根目录打开 PowerShell。
|
||||
|
||||
示例:
|
||||
|
||||
```powershell
|
||||
cd E:\isphere-ai-bridge
|
||||
```
|
||||
|
||||
如果仓库路径不同,请使用实际路径。下面所有命令都假设当前目录是仓库根目录。
|
||||
|
||||
检查仓库状态:
|
||||
|
||||
```powershell
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
建议稍后把该输出记录到采集包中,文件名为:
|
||||
|
||||
```text
|
||||
metadata/repo-status.txt
|
||||
```
|
||||
|
||||
## 3. 验证 helper 和 Go MCP 准备情况
|
||||
|
||||
运行 helper 验证:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
|
||||
```
|
||||
|
||||
期望的高层结果:
|
||||
|
||||
```text
|
||||
ok=true
|
||||
helper version=0.1.0
|
||||
uia_available=true 或有效布尔值
|
||||
```
|
||||
|
||||
如果内网沙盒已安装 Go,也运行:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
|
||||
```
|
||||
|
||||
期望的高层结果:
|
||||
|
||||
```text
|
||||
ok=true
|
||||
tool_count=4
|
||||
helper_name=ISphereWinHelper
|
||||
action_tools_present=false
|
||||
```
|
||||
|
||||
如果没有 Go,不要在采集窗口内临时安装无关工具,除非操作员已获得批准。下面的 C# helper 兜底流程足以采集同样四个只读 helper 操作。
|
||||
|
||||
## 4. 创建采集目录
|
||||
|
||||
选择一个采集编号。推荐格式:
|
||||
|
||||
```text
|
||||
YYYY-MM-DD-internal-sandbox-<short-name>
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```powershell
|
||||
$captureId = "2026-07-08-internal-sandbox-a"
|
||||
$captureRoot = "runs\internal-sandbox-live-capture\$captureId"
|
||||
New-Item -ItemType Directory -Force -Path `
|
||||
"$captureRoot\metadata", `
|
||||
"$captureRoot\helper", `
|
||||
"$captureRoot\windows", `
|
||||
"$captureRoot\uia", `
|
||||
"$captureRoot\notes" | Out-Null
|
||||
```
|
||||
|
||||
## 5. 记录元数据
|
||||
|
||||
记录操作系统版本:
|
||||
|
||||
```powershell
|
||||
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber, CsUserName |
|
||||
Format-List | Out-File -FilePath "$captureRoot\metadata\os-version.txt" -Encoding UTF8
|
||||
```
|
||||
|
||||
记录进程列表:
|
||||
|
||||
```powershell
|
||||
Get-Process | Sort-Object ProcessName |
|
||||
Select-Object ProcessName, Id, MainWindowTitle, Path |
|
||||
Format-Table -AutoSize | Out-File -FilePath "$captureRoot\metadata\process-list.txt" -Encoding UTF8
|
||||
```
|
||||
|
||||
记录仓库状态:
|
||||
|
||||
```powershell
|
||||
git status --short --branch | Out-File -FilePath "$captureRoot\metadata\repo-status.txt" -Encoding UTF8
|
||||
```
|
||||
|
||||
创建来源说明:
|
||||
|
||||
```powershell
|
||||
@"
|
||||
capture_id: $captureId
|
||||
source_environment: 内网测试沙盒
|
||||
operator: <填写操作员姓名或角色>
|
||||
capture_time_local: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz")
|
||||
iSphere manually logged in: 是/否
|
||||
client opened before capture: 是/否
|
||||
main window visible: 是/否
|
||||
operations used: version, self_check, scan_windows, dump_uia
|
||||
forbidden actions performed: no
|
||||
this package is N12R live evidence, not current-environment N12 pass
|
||||
"@ | Set-Content -Path "$captureRoot\metadata\source-notes.txt" -Encoding UTF8
|
||||
```
|
||||
|
||||
打包前,请把 `是/否` 字段改成真实情况。
|
||||
|
||||
## 6. 人工登录
|
||||
|
||||
1. 使用内网沙盒的正常方式打开 iSphere / IMPlatformClient。
|
||||
2. 按公司正常流程人工登录。
|
||||
3. 如需 SSO/MFA,请正常完成。
|
||||
4. 不要使用复制出来的凭据,也不要使用离线证据文件。
|
||||
5. 保持 iSphere 主窗口可见,不要最小化。
|
||||
|
||||
除非正常登录流程本身需要,否则不要点击联系人或会话。不要发送或接收任何内容。
|
||||
|
||||
## 7. 设置 helper 路径变量
|
||||
|
||||
运行 `verify-win-helper.ps1` 后,helper 应位于:
|
||||
|
||||
```text
|
||||
runs\win-helper\ISphereWinHelper.exe
|
||||
```
|
||||
|
||||
设置变量:
|
||||
|
||||
```powershell
|
||||
$helper = Resolve-Path -LiteralPath "runs\win-helper\ISphereWinHelper.exe"
|
||||
```
|
||||
|
||||
## 8. 采集 helper 版本
|
||||
|
||||
```powershell
|
||||
@{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "n12r-version"
|
||||
op = "version"
|
||||
timeout_ms = 5000
|
||||
args = @{}
|
||||
} | ConvertTo-Json -Depth 8 -Compress |
|
||||
& $helper --json |
|
||||
Set-Content -Path "$captureRoot\helper\version.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
## 9. 采集 self_check
|
||||
|
||||
```powershell
|
||||
@{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "n12r-self-check"
|
||||
op = "self_check"
|
||||
timeout_ms = 5000
|
||||
args = @{}
|
||||
} | ConvertTo-Json -Depth 8 -Compress |
|
||||
& $helper --json |
|
||||
Set-Content -Path "$captureRoot\helper\self-check.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
## 10. 扫描可见窗口
|
||||
|
||||
```powershell
|
||||
@{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "n12r-scan-windows"
|
||||
op = "scan_windows"
|
||||
timeout_ms = 5000
|
||||
args = @{ include_all_visible = $true }
|
||||
} | ConvertTo-Json -Depth 8 -Compress |
|
||||
& $helper --json |
|
||||
Set-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
打开扫描结果,根据进程名、标题、类名、可见性、边界和评分识别最可能的 iSphere / IMPlatformClient 窗口:
|
||||
|
||||
```powershell
|
||||
Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json |
|
||||
Select-Object -ExpandProperty data |
|
||||
Select-Object -ExpandProperty windows |
|
||||
Where-Object { $_.process_name -match "iSphere|IMPlatform|IMPP" -or $_.title -match "iSphere|IMPlatform|IMPP" } |
|
||||
Format-Table hwnd,pid,process_name,title,class_name,visible,score -AutoSize
|
||||
```
|
||||
|
||||
## 11. 保存所选窗口元数据
|
||||
|
||||
把 `<HWND>` 替换为扫描输出中的目标窗口句柄,例如 `0x001A0B2C`。
|
||||
|
||||
```powershell
|
||||
$selectedHwnd = "<HWND>"
|
||||
|
||||
$scanObj = Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json
|
||||
$selected = $scanObj.data.windows | Where-Object { $_.hwnd -eq $selectedHwnd } | Select-Object -First 1
|
||||
if (-not $selected) { throw "selected hwnd not found in scan result: $selectedHwnd" }
|
||||
$selected | ConvertTo-Json -Depth 8 | Set-Content -Path "$captureRoot\windows\selected-window.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
## 12. 采集 UIA dump
|
||||
|
||||
先使用保守限制:
|
||||
|
||||
```powershell
|
||||
@{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "n12r-dump-uia-main"
|
||||
op = "dump_uia"
|
||||
timeout_ms = 15000
|
||||
args = @{
|
||||
hwnd = $selectedHwnd
|
||||
max_depth = 8
|
||||
include_text = $true
|
||||
max_children = 100
|
||||
}
|
||||
} | ConvertTo-Json -Depth 12 -Compress |
|
||||
& $helper --json |
|
||||
Set-Content -Path "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
如果 dump 过大或被截断,可以调整限制后再重复一次,并另存为附加文件,例如:
|
||||
|
||||
```text
|
||||
uia/dump-uia-main-depth10-children150.json
|
||||
```
|
||||
|
||||
采集期间不要点击、输入、发送、上传、下载或修改 iSphere 客户端状态。
|
||||
|
||||
## 13. 创建脱敏 UIA 副本
|
||||
|
||||
创建用于共享的副本:
|
||||
|
||||
```powershell
|
||||
Copy-Item -LiteralPath "$captureRoot\uia\dump-uia-main.json" -Destination "$captureRoot\uia\dump-uia-main-redacted.json" -Force
|
||||
```
|
||||
|
||||
然后人工检查 `dump-uia-main-redacted.json`,在广泛共享前脱敏可见敏感文本。
|
||||
|
||||
需要脱敏:
|
||||
|
||||
```text
|
||||
人员姓名
|
||||
电话号码
|
||||
邮箱
|
||||
消息文本
|
||||
联系人名称
|
||||
组织敏感内容
|
||||
类似凭据/token 的字符串
|
||||
敏感业务文档标题
|
||||
```
|
||||
|
||||
尽量保留结构性 selector 字段:
|
||||
|
||||
```text
|
||||
control_type
|
||||
automation_id
|
||||
class_name
|
||||
framework_id
|
||||
bounds
|
||||
is_enabled
|
||||
is_offscreen
|
||||
children shape
|
||||
```
|
||||
|
||||
## 14. 写操作员备注
|
||||
|
||||
```powershell
|
||||
@"
|
||||
打开的客户端: <iSphere / IMPlatformClient>
|
||||
登录方式: 人工正常公司流程
|
||||
是否使用 MFA/SSO: 是/否,不包含任何秘密
|
||||
所选窗口 hwnd: $selectedHwnd
|
||||
选择该窗口的原因: <进程/标题/类名/评分原因>
|
||||
已做脱敏: <简要列表>
|
||||
采集问题: <简要列表>
|
||||
forbidden actions performed: no
|
||||
"@ | Set-Content -Path "$captureRoot\notes\operator-notes.txt" -Encoding UTF8
|
||||
```
|
||||
|
||||
## 15. 复制出去前验证采集包
|
||||
|
||||
运行:
|
||||
|
||||
```powershell
|
||||
$required = @(
|
||||
"$captureRoot\metadata\source-notes.txt",
|
||||
"$captureRoot\helper\version.json",
|
||||
"$captureRoot\helper\self-check.json",
|
||||
"$captureRoot\windows\scan-windows.json",
|
||||
"$captureRoot\windows\selected-window.json",
|
||||
"$captureRoot\uia\dump-uia-main.json",
|
||||
"$captureRoot\uia\dump-uia-main-redacted.json",
|
||||
"$captureRoot\notes\operator-notes.txt"
|
||||
)
|
||||
foreach ($path in $required) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $path" }
|
||||
}
|
||||
"N12R package files present: $captureRoot" | Tee-Object -FilePath "$captureRoot\metadata\verification-summary.txt"
|
||||
```
|
||||
|
||||
检查 JSON 是否可解析:
|
||||
|
||||
```powershell
|
||||
Get-Content "$captureRoot\helper\version.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\helper\self-check.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\windows\selected-window.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\uia\dump-uia-main-redacted.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
```
|
||||
|
||||
## 16. 打包传输
|
||||
|
||||
压缩采集目录:
|
||||
|
||||
```powershell
|
||||
Compress-Archive -LiteralPath $captureRoot -DestinationPath "$captureRoot.zip" -Force
|
||||
```
|
||||
|
||||
把目录或 zip 复制回外网仓库环境,并放到:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
## 17. 回报内容
|
||||
|
||||
告知协调会话:
|
||||
|
||||
```text
|
||||
N12R 采集包已准备好。
|
||||
路径: runs/internal-sandbox-live-capture/<capture-id>/
|
||||
人工 iSphere 登录: 是
|
||||
采集期间主窗口可见: 是
|
||||
forbidden actions performed: no
|
||||
```
|
||||
|
||||
随后由协调会话验证返回包。不要把原始当前环境 N12 标记为通过。
|
||||
141
docs/mcp-core-business-plan.md
Normal file
141
docs/mcp-core-business-plan.md
Normal 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.
|
||||
376
docs/mcp-core-tools-contract.md
Normal file
376
docs/mcp-core-tools-contract.md
Normal 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 |
|
||||
343
docs/msglib-read-sidecar-contract.md
Normal file
343
docs/msglib-read-sidecar-contract.md
Normal 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`.
|
||||
219
docs/n12r-final-handoff-checklist.md
Normal file
219
docs/n12r-final-handoff-checklist.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# N12R Final Handoff Checklist
|
||||
|
||||
Date: 2026-07-08
|
||||
|
||||
This is the short handoff entry for the current reality:
|
||||
|
||||
- The current repository environment is an outer-network coordination and analysis environment.
|
||||
- iSphere / IMPlatformClient is internal-network only.
|
||||
- Do not treat the current environment as capable of completing live login capture.
|
||||
- The active live-capture route is `N12R: Internal-sandbox live capture route`.
|
||||
- The original current-environment live-login gate remains blocked unless the repository environment itself is moved into the internal network and an operator can manually log in there.
|
||||
|
||||
## 1. Read this first
|
||||
|
||||
Use these files in this order:
|
||||
|
||||
```text
|
||||
docs/handoff-2026-07-06.md
|
||||
docs/n12r-final-handoff-checklist.md
|
||||
docs/internal-sandbox-live-capture-plan.md
|
||||
docs/internal-sandbox-operator-runbook.md
|
||||
docs/n12r-return-package-validation-checklist.md
|
||||
```
|
||||
|
||||
If you only need the immediate next action, follow section 3 and section 4 below.
|
||||
|
||||
## 2. Current node map
|
||||
|
||||
```text
|
||||
N0-N11
|
||||
Completed first-phase Go MCP / WinHelper foundation.
|
||||
|
||||
N12-pre
|
||||
Offline evidence intake and static analysis.
|
||||
Evidence input: runs/offline-evidence-intake/zyl
|
||||
Reports: runs/offline-evidence-intake-reports/zyl
|
||||
Status: useful supporting analysis, not a substitute for live UIA capture.
|
||||
|
||||
N12-current
|
||||
Current-environment real logged-in UIA capture.
|
||||
Status: blocked in this outer-network environment.
|
||||
|
||||
N12R
|
||||
Internal-sandbox live capture route.
|
||||
Status: active live-capture route.
|
||||
|
||||
N13
|
||||
Selector/UIA design review.
|
||||
Status: do not start until an N12R package is accepted.
|
||||
```
|
||||
|
||||
## 3. What the internal-network sandbox operator must do
|
||||
|
||||
The internal operator must use:
|
||||
|
||||
```text
|
||||
docs/internal-sandbox-operator-runbook.md
|
||||
```
|
||||
|
||||
Required operator flow:
|
||||
|
||||
1. Prepare the repository inside an internal-network test sandbox.
|
||||
2. Start iSphere / IMPlatformClient normally.
|
||||
3. Complete manual interactive login using the normal company process.
|
||||
4. Keep the main iSphere window visible.
|
||||
5. Run only read-only helper checks:
|
||||
- `version`
|
||||
- `self_check`
|
||||
- `scan_windows`
|
||||
- `dump_uia`
|
||||
6. Save the returned package under:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
Required returned package files:
|
||||
|
||||
```text
|
||||
metadata/source-notes.txt
|
||||
helper/version.json
|
||||
helper/self-check.json
|
||||
windows/scan-windows.json
|
||||
windows/selected-window.json
|
||||
uia/dump-uia-main.json
|
||||
uia/dump-uia-main-redacted.json
|
||||
notes/operator-notes.txt
|
||||
```
|
||||
|
||||
Recommended returned package files:
|
||||
|
||||
```text
|
||||
metadata/os-version.txt
|
||||
metadata/process-list.txt
|
||||
metadata/repo-status.txt
|
||||
metadata/verification-summary.txt
|
||||
```
|
||||
|
||||
## 4. What the outer-network receiver must do
|
||||
|
||||
After the internal sandbox returns a package, place it at:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
Then run the read-only validator:
|
||||
|
||||
```powershell
|
||||
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
|
||||
$reportRoot = "runs\internal-sandbox-live-capture-reports"
|
||||
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
|
||||
-PackageRoot $captureRoot `
|
||||
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
|
||||
```
|
||||
|
||||
Keep validation reports outside `$captureRoot`. The returned package is evidence input and should remain unchanged.
|
||||
|
||||
Run the validator regression tests before changing the validator:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-validate-n12r-return-package.ps1
|
||||
```
|
||||
|
||||
## 5. Acceptance gate for N12R
|
||||
|
||||
Accept an N12R package only if all of these are true:
|
||||
|
||||
1. Required files exist.
|
||||
2. Required JSON files parse.
|
||||
3. `helper/version.json` reports `ISphereWinHelper`.
|
||||
4. `helper/self-check.json` reports `ok=true` and UIA availability.
|
||||
5. `windows/scan-windows.json` includes an iSphere / IMPlatformClient candidate window.
|
||||
6. `windows/selected-window.json` records the selected hwnd and title or process metadata.
|
||||
7. `uia/dump-uia-main.json` contains a parseable UIA root/tree.
|
||||
8. `uia/dump-uia-main-redacted.json` exists and parses.
|
||||
9. Operator notes state manual login and no forbidden action.
|
||||
10. The validator decision is `ACCEPT`.
|
||||
|
||||
If any acceptance item fails, keep N12R open and request a corrected internal-sandbox capture.
|
||||
|
||||
## 6. Hard boundary
|
||||
|
||||
Allowed live operations remain only:
|
||||
|
||||
```text
|
||||
win_helper_version
|
||||
win_helper_self_check
|
||||
win_helper_scan_windows
|
||||
win_helper_dump_uia
|
||||
```
|
||||
|
||||
Do not add or use any tool for:
|
||||
|
||||
```text
|
||||
automatic login
|
||||
search contacts
|
||||
open conversation
|
||||
send message
|
||||
send file
|
||||
receive file
|
||||
upload file
|
||||
download file
|
||||
credential extraction
|
||||
injection
|
||||
hooking
|
||||
memory reading
|
||||
endpoint-security bypass
|
||||
```
|
||||
|
||||
Do not run copied client binaries from an evidence package.
|
||||
|
||||
## 7. Offline evidence status
|
||||
|
||||
Offline evidence already received:
|
||||
|
||||
```text
|
||||
runs/offline-evidence-intake/zyl
|
||||
```
|
||||
|
||||
Generated reports are outside the original evidence package:
|
||||
|
||||
```text
|
||||
runs/offline-evidence-intake-reports/zyl
|
||||
```
|
||||
|
||||
Important offline conclusions:
|
||||
|
||||
- Two IMPlatformClient builds were observed: `4.1.2.6842` and `4.1.2.6839`.
|
||||
- `MsgLib.db` artifacts are not standard SQLite.
|
||||
- Static .NET metadata and logs support format/owner hypotheses.
|
||||
- Offline evidence is useful for context, but it cannot prove live UIA selector behavior.
|
||||
|
||||
## 8. Environment caveats
|
||||
|
||||
Known local caveat:
|
||||
|
||||
```text
|
||||
go is not installed in the current outer-network environment
|
||||
```
|
||||
|
||||
This affects `scripts/verify-go-mcp.ps1` in this environment only. It does not change the N12R route.
|
||||
|
||||
## 9. Next decision after N12R acceptance
|
||||
|
||||
If the returned N12R package is accepted:
|
||||
|
||||
1. Record the validation report path.
|
||||
2. Review the UIA tree structure.
|
||||
3. Decide whether selector design is stable enough for N13.
|
||||
4. Write a separate N13 selector design plan before adding any selector logic.
|
||||
|
||||
If the returned N12R package is rejected:
|
||||
|
||||
1. Keep N12R as active.
|
||||
2. Send the validation failure list back to the internal-network operator.
|
||||
3. Request a corrected read-only capture.
|
||||
341
docs/n12r-internal-operator-minimal-retry-2026-07-09.md
Normal file
341
docs/n12r-internal-operator-minimal-retry-2026-07-09.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# N12R 内网操作员最小重做步骤
|
||||
|
||||
日期:2026-07-09
|
||||
|
||||
适用对象:内网测试沙盒操作员。
|
||||
|
||||
目的:修正本次 N12R 返回包中 `selected-window.json` 缺失和 `dump_uia` 失败的问题,重新生成一个可被外网校验接受的 N12R 采集包。
|
||||
|
||||
## 1. 本次失败原因
|
||||
|
||||
本次返回包被外网校验拒收,核心原因是窗口句柄写错了。
|
||||
|
||||
日志中写成了:
|
||||
|
||||
```powershell
|
||||
$selectedHwnd = "<0x290E6E>"
|
||||
```
|
||||
|
||||
这是错误写法。`<HWND>` 里的尖括号只是提示格式,实际填写时必须删掉。
|
||||
|
||||
正确写法是:
|
||||
|
||||
```powershell
|
||||
$selectedHwnd = "0x290E6E"
|
||||
```
|
||||
|
||||
因为多了尖括号,后续窗口匹配失败:
|
||||
|
||||
```text
|
||||
selected hwnd not found in scan result: <0x290E6E>
|
||||
```
|
||||
|
||||
所以:
|
||||
|
||||
1. `windows/selected-window.json` 没有生成。
|
||||
2. `dump_uia` 使用了无效 hwnd。
|
||||
3. `uia/dump-uia-main.json` 返回 `WINDOW_NOT_FOUND`。
|
||||
|
||||
## 2. 重做前确认
|
||||
|
||||
在内网测试沙盒中打开 PowerShell,进入仓库根目录:
|
||||
|
||||
```powershell
|
||||
cd E:\isphere-ai-bridge
|
||||
```
|
||||
|
||||
确认 iSphere / IMPlatformClient 已经人工登录,并且主窗口可见、没有最小化。
|
||||
|
||||
不要执行以下动作:
|
||||
|
||||
```text
|
||||
搜索联系人
|
||||
打开会话
|
||||
发送消息
|
||||
发送文件
|
||||
接收/下载文件
|
||||
自动登录
|
||||
注入
|
||||
hook
|
||||
读取内存
|
||||
绕过安防
|
||||
```
|
||||
|
||||
## 3. 推荐做法:重新运行 scan_windows
|
||||
|
||||
窗口句柄可能已经变化,所以推荐先重新扫描窗口。
|
||||
|
||||
如果沿用原采集编号:
|
||||
|
||||
```powershell
|
||||
$captureId = "2026-07-08-internal-sandbox-a"
|
||||
$captureRoot = "runs\internal-sandbox-live-capture\$captureId"
|
||||
$helper = Resolve-Path -LiteralPath "runs\win-helper\ISphereWinHelper.exe"
|
||||
```
|
||||
|
||||
重新扫描可见窗口:
|
||||
|
||||
```powershell
|
||||
@{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "n12r-scan-windows-retry"
|
||||
op = "scan_windows"
|
||||
timeout_ms = 5000
|
||||
args = @{ include_all_visible = $true }
|
||||
} | ConvertTo-Json -Depth 8 -Compress |
|
||||
& $helper --json |
|
||||
Set-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
查看候选窗口:
|
||||
|
||||
```powershell
|
||||
Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json |
|
||||
Select-Object -ExpandProperty data |
|
||||
Select-Object -ExpandProperty windows |
|
||||
Where-Object { $_.process_name -match "iSphere|IMPlatform|IMPP" -or $_.title -match "iSphere|IMPlatform|IMPP" } |
|
||||
Format-Table hwnd,pid,process_name,title,class_name,visible,score -AutoSize
|
||||
```
|
||||
|
||||
从输出中选择 iSphere / IMPlatformClient 主窗口的 `hwnd`。
|
||||
|
||||
示例中之前的候选值是:
|
||||
|
||||
```text
|
||||
0x290E6E
|
||||
```
|
||||
|
||||
如果这次扫描输出变了,请使用新的 `hwnd`。
|
||||
|
||||
## 4. 正确保存 selected-window.json
|
||||
|
||||
把下面命令中的 `0x290E6E` 改成你刚刚确认的真实窗口句柄。
|
||||
|
||||
注意:不要加尖括号。
|
||||
|
||||
正确:
|
||||
|
||||
```powershell
|
||||
$selectedHwnd = "0x290E6E"
|
||||
```
|
||||
|
||||
错误:
|
||||
|
||||
```powershell
|
||||
$selectedHwnd = "<0x290E6E>"
|
||||
```
|
||||
|
||||
保存所选窗口元数据:
|
||||
|
||||
```powershell
|
||||
$selectedHwnd = "0x290E6E"
|
||||
|
||||
$scanObj = Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json
|
||||
$selected = $scanObj.data.windows | Where-Object { $_.hwnd -eq $selectedHwnd } | Select-Object -First 1
|
||||
if (-not $selected) { throw "selected hwnd not found in scan result: $selectedHwnd" }
|
||||
$selected | ConvertTo-Json -Depth 8 | Set-Content -Path "$captureRoot\windows\selected-window.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
确认文件已经生成:
|
||||
|
||||
```powershell
|
||||
Test-Path -LiteralPath "$captureRoot\windows\selected-window.json"
|
||||
```
|
||||
|
||||
期望输出:
|
||||
|
||||
```text
|
||||
True
|
||||
```
|
||||
|
||||
## 5. 重新执行 dump_uia
|
||||
|
||||
```powershell
|
||||
@{
|
||||
protocol = "isphere.helper.v1"
|
||||
request_id = "n12r-dump-uia-main-retry"
|
||||
op = "dump_uia"
|
||||
timeout_ms = 15000
|
||||
args = @{
|
||||
hwnd = $selectedHwnd
|
||||
max_depth = 8
|
||||
include_text = $true
|
||||
max_children = 100
|
||||
}
|
||||
} | ConvertTo-Json -Depth 12 -Compress |
|
||||
& $helper --json |
|
||||
Set-Content -Path "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8
|
||||
```
|
||||
|
||||
检查结果是否成功:
|
||||
|
||||
```powershell
|
||||
$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json
|
||||
$dump.ok
|
||||
$dump.error
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
True
|
||||
```
|
||||
|
||||
并且 `$dump.error` 应为空或为 `null`。
|
||||
|
||||
如果仍然是 `WINDOW_NOT_FOUND`,说明窗口句柄已经失效。请回到第 3 步重新扫描并选择新的 hwnd。
|
||||
|
||||
## 6. 重新创建脱敏副本
|
||||
|
||||
```powershell
|
||||
Copy-Item -LiteralPath "$captureRoot\uia\dump-uia-main.json" -Destination "$captureRoot\uia\dump-uia-main-redacted.json" -Force
|
||||
```
|
||||
|
||||
然后人工打开:
|
||||
|
||||
```text
|
||||
$captureRoot\uia\dump-uia-main-redacted.json
|
||||
```
|
||||
|
||||
脱敏可见敏感文本,例如:
|
||||
|
||||
```text
|
||||
人员姓名
|
||||
手机号
|
||||
邮箱
|
||||
消息文本
|
||||
联系人名称
|
||||
敏感业务标题
|
||||
类似 token / 密码 / cookie 的字符串
|
||||
```
|
||||
|
||||
尽量保留这些结构字段:
|
||||
|
||||
```text
|
||||
control_type
|
||||
automation_id
|
||||
class_name
|
||||
framework_id
|
||||
bounds
|
||||
is_enabled
|
||||
is_offscreen
|
||||
children
|
||||
```
|
||||
|
||||
## 7. 修正 source-notes.txt
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
$captureRoot\metadata\source-notes.txt
|
||||
```
|
||||
|
||||
把这些字段从 `是/否` 改成真实值:
|
||||
|
||||
```text
|
||||
iSphere manually logged in: 是
|
||||
client opened before capture: 是
|
||||
main window visible: 是
|
||||
```
|
||||
|
||||
如果真实情况不是“是”,不要继续打包,先重新完成对应动作。
|
||||
|
||||
## 8. 修正 operator-notes.txt
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
$captureRoot\notes\operator-notes.txt
|
||||
```
|
||||
|
||||
不要保留 `<...>` 示例内容。请改成真实说明。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
打开的客户端: iSphere / IMPlatformClient
|
||||
登录方式: 人工正常公司流程
|
||||
是否使用 MFA/SSO: 是,不包含任何秘密
|
||||
所选窗口 hwnd: 0x290E6E
|
||||
选择该窗口的原因: process_name 为 IMPlatformClient,标题为国网甘肃省电力公司,visible=True,score=65
|
||||
已做脱敏: 已检查 dump-uia-main-redacted.json,移除可见敏感文本
|
||||
采集问题: 首次把 hwnd 写成 <0x290E6E> 导致失败,本次已修正为 0x290E6E 后重新采集
|
||||
forbidden actions performed: no
|
||||
```
|
||||
|
||||
## 9. 复制出去前必须验证
|
||||
|
||||
运行必需文件检查:
|
||||
|
||||
```powershell
|
||||
$required = @(
|
||||
"$captureRoot\metadata\source-notes.txt",
|
||||
"$captureRoot\helper\version.json",
|
||||
"$captureRoot\helper\self-check.json",
|
||||
"$captureRoot\windows\scan-windows.json",
|
||||
"$captureRoot\windows\selected-window.json",
|
||||
"$captureRoot\uia\dump-uia-main.json",
|
||||
"$captureRoot\uia\dump-uia-main-redacted.json",
|
||||
"$captureRoot\notes\operator-notes.txt"
|
||||
)
|
||||
foreach ($path in $required) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $path" }
|
||||
}
|
||||
"N12R retry package files present: $captureRoot" | Tee-Object -FilePath "$captureRoot\metadata\verification-summary.txt"
|
||||
```
|
||||
|
||||
运行 JSON 解析检查:
|
||||
|
||||
```powershell
|
||||
Get-Content "$captureRoot\helper\version.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\helper\self-check.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\windows\selected-window.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
Get-Content "$captureRoot\uia\dump-uia-main-redacted.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
```
|
||||
|
||||
检查 UIA dump 必须是成功:
|
||||
|
||||
```powershell
|
||||
$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json
|
||||
if (-not $dump.ok) { throw "dump_uia failed: $($dump.error | ConvertTo-Json -Depth 8 -Compress)" }
|
||||
```
|
||||
|
||||
## 10. 重新压缩返回
|
||||
|
||||
```powershell
|
||||
Compress-Archive -LiteralPath $captureRoot -DestinationPath "$captureRoot.zip" -Force
|
||||
```
|
||||
|
||||
把新的 zip 或目录返回给外网协调环境。
|
||||
|
||||
返回时请同时说明:
|
||||
|
||||
```text
|
||||
N12R 重采/补采包已准备好。
|
||||
路径: runs/internal-sandbox-live-capture/2026-07-08-internal-sandbox-a/
|
||||
人工 iSphere 登录: 是
|
||||
采集期间主窗口可见: 是
|
||||
forbidden actions performed: no
|
||||
本次已修正 hwnd,不再包含尖括号;selected-window.json 已生成;dump_uia ok=true
|
||||
```
|
||||
|
||||
## 11. 外网收到后如何判断
|
||||
|
||||
外网协调环境会重新运行:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
|
||||
-PackageRoot <返回包目录> `
|
||||
-ReportPath <包外报告路径>
|
||||
```
|
||||
|
||||
只有 validator 输出:
|
||||
|
||||
```text
|
||||
Decision: ACCEPT
|
||||
```
|
||||
|
||||
才能进入 N13 selector 设计。
|
||||
|
||||
223
docs/n12r-return-package-validation-checklist.md
Normal file
223
docs/n12r-return-package-validation-checklist.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# N12R Return Package Validation Checklist
|
||||
|
||||
Use this checklist in the outer-network coordination environment after an internal-network operator returns a live capture package.
|
||||
|
||||
N12R packages are expected under:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
This checklist validates package completeness and boundary compliance. It does not execute copied client binaries and does not mark original current-environment N12 as passed.
|
||||
|
||||
## 1. Required files
|
||||
|
||||
A package is structurally complete only if these files exist:
|
||||
|
||||
```text
|
||||
metadata/source-notes.txt
|
||||
helper/version.json
|
||||
helper/self-check.json
|
||||
windows/scan-windows.json
|
||||
windows/selected-window.json
|
||||
uia/dump-uia-main.json
|
||||
uia/dump-uia-main-redacted.json
|
||||
notes/operator-notes.txt
|
||||
```
|
||||
|
||||
Recommended metadata files:
|
||||
|
||||
```text
|
||||
metadata/os-version.txt
|
||||
metadata/process-list.txt
|
||||
metadata/repo-status.txt
|
||||
metadata/verification-summary.txt
|
||||
```
|
||||
|
||||
## 2. Read-only validation commands
|
||||
|
||||
Set package path:
|
||||
|
||||
```powershell
|
||||
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
|
||||
```
|
||||
|
||||
Preferred one-command validation:
|
||||
|
||||
```powershell
|
||||
$reportRoot = "runs\internal-sandbox-live-capture-reports"
|
||||
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
|
||||
-PackageRoot $captureRoot `
|
||||
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
|
||||
```
|
||||
|
||||
The report path must stay outside the returned package root so validation does not modify the returned evidence package.
|
||||
|
||||
Check required files:
|
||||
|
||||
```powershell
|
||||
$required = @(
|
||||
"metadata\source-notes.txt",
|
||||
"helper\version.json",
|
||||
"helper\self-check.json",
|
||||
"windows\scan-windows.json",
|
||||
"windows\selected-window.json",
|
||||
"uia\dump-uia-main.json",
|
||||
"uia\dump-uia-main-redacted.json",
|
||||
"notes\operator-notes.txt"
|
||||
)
|
||||
foreach ($rel in $required) {
|
||||
$path = Join-Path $captureRoot $rel
|
||||
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $rel" }
|
||||
}
|
||||
```
|
||||
|
||||
Check JSON parseability:
|
||||
|
||||
```powershell
|
||||
$jsonFiles = @(
|
||||
"helper\version.json",
|
||||
"helper\self-check.json",
|
||||
"windows\scan-windows.json",
|
||||
"windows\selected-window.json",
|
||||
"uia\dump-uia-main.json",
|
||||
"uia\dump-uia-main-redacted.json"
|
||||
)
|
||||
foreach ($rel in $jsonFiles) {
|
||||
Get-Content -LiteralPath (Join-Path $captureRoot $rel) -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Metadata acceptance checks
|
||||
|
||||
`metadata/source-notes.txt` must state:
|
||||
|
||||
```text
|
||||
source_environment: internal-network test sandbox
|
||||
iSphere manually logged in: yes
|
||||
client opened before capture: yes
|
||||
main window visible: yes
|
||||
operations used: version, self_check, scan_windows, dump_uia
|
||||
forbidden actions performed: no
|
||||
this package is N12R live evidence, not current-environment N12 pass
|
||||
```
|
||||
|
||||
`notes/operator-notes.txt` must state:
|
||||
|
||||
```text
|
||||
How login was performed: manual normal company process
|
||||
Forbidden actions performed: no
|
||||
```
|
||||
|
||||
If either note file says automatic login, sending, receiving, injection, hook, memory reading, or bypass occurred, reject the package.
|
||||
|
||||
## 4. Helper JSON checks
|
||||
|
||||
`helper/version.json` should parse to an object where:
|
||||
|
||||
```text
|
||||
ok == true
|
||||
data.helper_name == ISphereWinHelper
|
||||
```
|
||||
|
||||
`helper/self-check.json` should parse to an object where:
|
||||
|
||||
```text
|
||||
ok == true
|
||||
data.uia_available is a boolean
|
||||
```
|
||||
|
||||
## 5. Window scan checks
|
||||
|
||||
`windows/scan-windows.json` should include at least one candidate where one or more of these fields indicates iSphere / IMPlatformClient / IMPP:
|
||||
|
||||
```text
|
||||
process_name
|
||||
title
|
||||
class_name
|
||||
score
|
||||
```
|
||||
|
||||
`windows/selected-window.json` should be one selected object from `scan-windows.json` and should include:
|
||||
|
||||
```text
|
||||
hwnd
|
||||
pid
|
||||
process_name
|
||||
title
|
||||
class_name
|
||||
visible
|
||||
bounds
|
||||
score
|
||||
```
|
||||
|
||||
## 6. UIA dump checks
|
||||
|
||||
`uia/dump-uia-main.json` should parse to a helper response where:
|
||||
|
||||
```text
|
||||
ok == true
|
||||
data.root exists
|
||||
```
|
||||
|
||||
The UIA root tree should include selector-useful fields such as:
|
||||
|
||||
```text
|
||||
name
|
||||
control_type
|
||||
automation_id
|
||||
class_name
|
||||
framework_id
|
||||
bounds
|
||||
children
|
||||
```
|
||||
|
||||
The redacted copy `uia/dump-uia-main-redacted.json` must exist before broad sharing.
|
||||
|
||||
## 7. Boundary rejection checks
|
||||
|
||||
Reject the package for N12R evidence use if any of these are true:
|
||||
|
||||
```text
|
||||
source notes do not confirm manual login
|
||||
main window was not visible
|
||||
scan result has no iSphere / IMPlatformClient candidate
|
||||
UIA dump is missing or empty
|
||||
redacted UIA copy is missing
|
||||
package includes raw passwords, tokens, cookies, private keys, or copied credential material
|
||||
operator notes indicate automatic login, search, opening conversations, sending, receiving, injection, hook, memory reading, or endpoint-security bypass
|
||||
package was produced by running copied executables from an offline evidence package
|
||||
```
|
||||
|
||||
## 8. Validation result template
|
||||
|
||||
After validation, write a short report under:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/metadata/outer-validation-summary.txt
|
||||
```
|
||||
|
||||
Template:
|
||||
|
||||
```text
|
||||
capture_id: <capture-id>
|
||||
validated_at: <local time>
|
||||
validated_by: outer-network coordination environment
|
||||
required_files_present: yes/no
|
||||
json_parse_ok: yes/no
|
||||
manual_login_confirmed: yes/no
|
||||
main_window_visible_confirmed: yes/no
|
||||
candidate_window_found: yes/no
|
||||
uia_dump_ok: yes/no
|
||||
redacted_copy_present: yes/no
|
||||
forbidden_actions_indicated: yes/no
|
||||
validation_result: accepted/rejected
|
||||
notes: <short notes>
|
||||
```
|
||||
|
||||
## 9. Next decision after acceptance
|
||||
|
||||
If accepted, N12R evidence can be used for a selector-design review gate.
|
||||
|
||||
Do not implement selectors or action tools directly from this checklist. Selector design requires a separate written plan and explicit approval.
|
||||
@@ -426,6 +426,36 @@ True N12 can start only when all of these are true:
|
||||
|
||||
If the current environment still cannot log in, only N12-pre offline analysis is allowed. N12 remains blocked and must not be marked passed.
|
||||
|
||||
## 8.1 Reality update: internal-sandbox live capture route
|
||||
|
||||
As of 2026-07-08, the repository environment is outside the internal network, while iSphere / IMPlatformClient is internal-network only. Therefore the active live-capture route is not current-environment N12. It is:
|
||||
|
||||
```text
|
||||
N12R: Internal-sandbox live capture route
|
||||
```
|
||||
|
||||
Use these documents for N12R:
|
||||
|
||||
```text
|
||||
docs/internal-sandbox-live-capture-plan.md
|
||||
docs/internal-sandbox-operator-runbook.md
|
||||
docs/n12r-return-package-validation-checklist.md
|
||||
```
|
||||
|
||||
N12-pre offline packages continue to support static file inventory and offline analysis. N12R packages support live read-only UIA evidence collected from an internal-network test sandbox after manual login.
|
||||
|
||||
Returned N12R packages should be placed under:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture/<capture-id>/
|
||||
```
|
||||
|
||||
Do not mix N12-pre and N12R semantics:
|
||||
|
||||
- `runs/offline-evidence-intake/<evidence-id>/` is copied-file offline evidence only.
|
||||
- `runs/internal-sandbox-live-capture/<capture-id>/` is live read-only UIA capture from the internal sandbox.
|
||||
- Neither route authorizes automatic login, sending, receiving, injection, hook, memory reading, or endpoint-security bypass.
|
||||
|
||||
## 9. Coordinator request checklist for the user
|
||||
|
||||
Use this final user-facing request. It intentionally avoids asking the user to understand JSON.
|
||||
|
||||
30
docs/reports/2026-07-10-business-goals-smoke.md
Normal file
30
docs/reports/2026-07-10-business-goals-smoke.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Business Goals Smoke - 2026-07-10
|
||||
|
||||
## 业务目标状态
|
||||
|
||||
| 目标 | 当前状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 搜索联系人 | 可用 | 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。
|
||||
|
||||
数字员工还不能使用:生产发消息、真实下载文件、生产发文件。
|
||||
108
docs/reports/2026-07-10-r6f-r14-release-candidate.md
Normal file
108
docs/reports/2026-07-10-r6f-r14-release-candidate.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# R6f-R14 Release Candidate Status - 2026-07-10
|
||||
|
||||
本报告是 R6f-R14 连续轮次的业务验收材料。结论先讲清楚:数字员工现在可以用搜索、收消息基础读取、收文件列表,以及发消息/发文件 preview;生产发消息、真实下载文件、生产发文件仍然需要在线可登录环境返回证据。
|
||||
|
||||
## Business capability status
|
||||
|
||||
| 业务能力 | 状态分类 | 当前结论 | 证据/说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 搜索联系人 | 已完成:可直接给数字员工用 | 可用 | `isphere_search_contacts` 已注册;支持 PacketReader/log-backed 候选和可选 MsgLib display enrichment;R13 smoke 通过。 |
|
||||
| 搜索群组 | 已完成:可直接给数字员工用 | 可用 | `isphere_search_groups` 已注册;支持 groupchat/conference 候选、排序、去重和可选 MsgLib enrichment;R13 smoke 通过。 |
|
||||
| 收消息 | 已完成:可直接给数字员工用 | 基础读取可用 | 默认 PacketReader/log-backed;显式 `source_preference="msglib_readonly"` 可走只读 MsgLib;R12 已有对账 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 完成;真实下载和生产发文件阻断。
|
||||
|
||||
因此当前不是“全破解完成”,而是“可读/可搜/可规划已经可交付;真实写入和真实下载进入在线证据门禁”。
|
||||
111
docs/source-discovery/2026-07-09-n12-pre-zyl-index.md
Normal file
111
docs/source-discovery/2026-07-09-n12-pre-zyl-index.md
Normal 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.
|
||||
127
docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md
Normal file
127
docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
49
docs/source-discovery/2026-07-10-file-download-mapping-v2.md
Normal file
49
docs/source-discovery/2026-07-10-file-download-mapping-v2.md
Normal 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`.
|
||||
128
docs/source-discovery/2026-07-10-live-probe-recorder.md
Normal file
128
docs/source-discovery/2026-07-10-live-probe-recorder.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
173
docs/source-discovery/2026-07-10-returned-live-probe-analysis.md
Normal file
173
docs/source-discovery/2026-07-10-returned-live-probe-analysis.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
139
docs/source-discovery/2026-07-10-send-connector-preflight.md
Normal file
139
docs/source-discovery/2026-07-10-send-connector-preflight.md
Normal 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.
|
||||
68
docs/source-discovery/2026-07-10-send-file-sandbox-gate.md
Normal file
68
docs/source-discovery/2026-07-10-send-file-sandbox-gate.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
151
docs/source-discovery/2026-07-10-send-sandbox-gate.md
Normal file
151
docs/source-discovery/2026-07-10-send-sandbox-gate.md
Normal 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.
|
||||
360
docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md
Normal file
360
docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md
Normal 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.
|
||||
@@ -0,0 +1,120 @@
|
||||
# A-route open_conversation 窗口验证说明
|
||||
|
||||
日期:2026-07-11
|
||||
分支:`codex/a-route-rpa-send`
|
||||
|
||||
## 本轮目标
|
||||
|
||||
用户明确当前先不要求真实发送消息,只要求“打开那个会话窗口”,用来验证数字员工是否能:
|
||||
|
||||
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`
|
||||
|
||||
仍未成功打开真实窗体。当前阻塞不再是脚本能力,而是构造函数内部把窗体初始化、登录态、连接态、名册状态、消息中心事件、配置状态混在一起。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`
|
||||
|
||||
这说明真实 `frmP2PChat` 不是独立窗口类;它要求完整登录运行态。没有服务端/登录态时继续硬构造,投入会越来越像“重建一个假客户端运行时”。
|
||||
|
||||
## 当前业务结论
|
||||
|
||||
从“验证 RPA 能否识别、定位、点击会话窗口”的业务目标看,本轮已经可验证:
|
||||
|
||||
- 会话窗口可打开。
|
||||
- 核心 AutomationId 可识别。
|
||||
- 文本可写入。
|
||||
- 按钮可点击。
|
||||
- 点击结果可留本地审计 marker。
|
||||
- 全程不发真实消息、不上传真实文件。
|
||||
|
||||
从“真实 iSphere 会话窗口”的目标看,仍需要在线登录环境或继续补完整客户端运行态。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. 当前分支先使用 `open-offline-chat-window-probe.ps1` 验证 RPA 识别/定位/点击效果。
|
||||
2. 在线环境拿到真实登录后的 `frmP2PChat` HWND 后,用同一套 WinHelper 选择器直接验证真实窗口。
|
||||
3. 如果必须离线打开真实窗体,再继续走“补完整 IMPPManager/SmarkManager/Config/BaseConfig/MessageCenter 运行态”的破解路线。
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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 需要上传文件,需要另开节点定位真实工具栏按钮或菜单路径。
|
||||
|
||||
## 下一轮建议
|
||||
|
||||
1. 把 A-route RPA 的窗口定位目标从 synthetic probe 切到真实 `frmP2PChat`:
|
||||
- root `automation_id=frmP2PChat`
|
||||
- send box `automation_id=rtbSendMessage`
|
||||
- send button `automation_id=btnSend`
|
||||
2. 如果弹出“企业信息获取失败”影响自动化,单独加一个非阻塞关闭弹窗步骤。
|
||||
3. 如果后续需要文件按钮,再专门分析真实工具栏图标按钮、菜单项和 `ToolStrip` 命中路径。
|
||||
30
docs/source-discovery/2026-07-11-a-route-uia-send.md
Normal file
30
docs/source-discovery/2026-07-11-a-route-uia-send.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# A-route UIA Send Implementation Note
|
||||
|
||||
Date: 2026-07-11
|
||||
Branch: `codex/a-route-rpa-send`
|
||||
|
||||
## 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"`.
|
||||
|
||||
## Remaining real-environment work
|
||||
|
||||
The local environment still cannot log in to iSphere. The code path is implemented and locally proven against synthetic UIA, but real iSphere success still needs a logged-in window handle and one real run in the online environment.
|
||||
175
docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md
Normal file
175
docs/source-discovery/2026-07-11-a-route-user-silent-rpa.md
Normal 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 用户会话里做,不能放当前用户桌面长期运行。
|
||||
49
docs/source-discovery/README.md
Normal file
49
docs/source-discovery/README.md
Normal 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.
|
||||
53
docs/source-discovery/capability-source-matrix.md
Normal file
53
docs/source-discovery/capability-source-matrix.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Capability Source Matrix
|
||||
|
||||
Date: 2026-07-10
|
||||
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`
|
||||
|
||||
## 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.
|
||||
|
||||
## 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: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path; current MCP tool exposes preview/dry-run only | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; 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`; `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: `operator_observed_success=true`, but `exact_content_seen_after=false`, `content_sha256_seen_after=false`, `after_offline_blocked=true`, and `production_send_unlock_recommended=false`. Preview is complete; production remains blocked. | blocked until strict machine sent-record/content-hash after evidence passes and a connector is configured |
|
||||
| `isphere_send_file` | B-route selected after send-message probe: managed offline-file path through `OffLineFileSend.sendFile()`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, and `Chat.sendFileMessage(...)`; current MCP tool exposes preview/dry-run only | A-route fallback: constrained UI/RPA upload/send-file flow; native `TcpFileTransfer.dll` is a later candidate only after managed path is rejected | `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`; `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. | blocked until returned file-send package proves upload/sent evidence and an upload connector is gated |
|
||||
|
||||
## 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. The active continuous execution plan `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md` is complete through R14; next work is evidence-driven, not local login-dependent.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,943 @@
|
||||
# N13 UIA Selector Catalog 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, read-only N13 UIA selector catalog and matcher that can validate selectors against the accepted N12R UIA dump without adding live helper or MCP actions.
|
||||
|
||||
**Architecture:** Add a focused Go package `internal/uiaselector` with selector data types, a static first-pass catalog, UIA dump loading, and pure in-memory matching. Tests use a redacted N12R fixture and assert that matching returns structural node summaries without visible text.
|
||||
|
||||
**Tech Stack:** Go 1.23.x, standard library `encoding/json`, existing repository test style, PowerShell verification wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Scope and boundary
|
||||
|
||||
This plan implements only offline selector catalog and matching.
|
||||
|
||||
It does not modify these files:
|
||||
|
||||
```text
|
||||
native/ISphereWinHelper/Program.cs
|
||||
internal/tools/winhelper.go
|
||||
cmd/isphere-mcp/main.go
|
||||
```
|
||||
|
||||
It does not add MCP tools or helper ops. It does not click, type, open conversations, send messages, send files, receive files, or read chat content.
|
||||
|
||||
The accepted N12R evidence used by tests is:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main-redacted.json
|
||||
```
|
||||
|
||||
## File structure
|
||||
|
||||
Create these files:
|
||||
|
||||
```text
|
||||
internal/uiaselector/catalog.go
|
||||
internal/uiaselector/catalog_test.go
|
||||
internal/uiaselector/dump.go
|
||||
internal/uiaselector/dump_test.go
|
||||
internal/uiaselector/matcher.go
|
||||
internal/uiaselector/matcher_test.go
|
||||
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
|
||||
scripts/verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `catalog.go`: selector data structures and initial N13 selector catalog.
|
||||
- `catalog_test.go`: catalog completeness and boundary tests.
|
||||
- `dump.go`: UIA dump response and node loading helpers.
|
||||
- `dump_test.go`: fixture parse tests.
|
||||
- `matcher.go`: pure in-memory selector matcher.
|
||||
- `matcher_test.go`: matching behavior and no-visible-text result tests.
|
||||
- `testdata/n12r-2026-07-09-uia-redacted.json`: redacted accepted N12R UIA fixture copied from the ignored run artifact.
|
||||
- `scripts/verify-n13-uia-selectors.ps1`: convenience verification script for Go-enabled environments.
|
||||
|
||||
## Task 1: Add N12R redacted UIA fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json`
|
||||
|
||||
- [ ] **Step 1: Create package directory and copy the accepted redacted fixture**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path internal\uiaselector\testdata | Out-Null
|
||||
Copy-Item `
|
||||
-LiteralPath runs\internal-sandbox-live-capture-intake\2026-07-09-retry-2\2026-07-08-internal-sandbox-a\uia\dump-uia-main-redacted.json `
|
||||
-Destination internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json `
|
||||
-Force
|
||||
```
|
||||
|
||||
Expected: `internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json` exists.
|
||||
|
||||
- [ ] **Step 2: Verify fixture parses as JSON**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Get-Content internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
```
|
||||
|
||||
Expected: command exits 0.
|
||||
|
||||
- [ ] **Step 3: Commit fixture**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json
|
||||
git commit -m "test: add N13 redacted UIA fixture"
|
||||
```
|
||||
|
||||
## Task 2: Define selector catalog types and initial catalog
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/catalog_test.go`
|
||||
- Create: `internal/uiaselector/catalog.go`
|
||||
|
||||
- [ ] **Step 1: Write failing catalog tests**
|
||||
|
||||
Create `internal/uiaselector/catalog_test.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
|
||||
catalog := DefaultCatalog()
|
||||
wantIDs := []string{
|
||||
"main_window",
|
||||
"main_plugin_tab",
|
||||
"home_page_window",
|
||||
"tab_page_content",
|
||||
"left_panel",
|
||||
"roster_panel",
|
||||
"roster_view",
|
||||
"fuzzy_query_panel",
|
||||
"fuzzy_query_window",
|
||||
"fuzzy_search_edit",
|
||||
}
|
||||
|
||||
byID := map[string]Selector{}
|
||||
for _, selector := range catalog {
|
||||
byID[selector.ID] = selector
|
||||
}
|
||||
|
||||
for _, id := range wantIDs {
|
||||
selector, ok := byID[id]
|
||||
if !ok {
|
||||
t.Fatalf("DefaultCatalog missing selector %q", id)
|
||||
}
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
|
||||
}
|
||||
if selector.Required.AutomationID == "" {
|
||||
t.Fatalf("selector %q must require automation_id", id)
|
||||
}
|
||||
if selector.Required.ControlType == "" {
|
||||
t.Fatalf("selector %q must require control_type", id)
|
||||
}
|
||||
if selector.StabilityScore < 80 {
|
||||
t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
|
||||
for _, selector := range DefaultCatalog() {
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
|
||||
}
|
||||
lower := strings.ToLower(selector.ID + " " + selector.Description)
|
||||
for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
|
||||
if strings.Contains(lower, forbidden) {
|
||||
t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run catalog tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because package `internal/uiaselector` or `DefaultCatalog` does not exist.
|
||||
|
||||
If this environment reports `go` not found, stop execution and record the environment gap. Continue only in a Go 1.23.x environment.
|
||||
|
||||
- [ ] **Step 3: Implement catalog types and selectors**
|
||||
|
||||
Create `internal/uiaselector/catalog.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
const AllowedUseReadOnlyLocate = "read_only_locate"
|
||||
|
||||
type Selector struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
AllowedUse string `json:"allowed_use"`
|
||||
Required Criteria `json:"required"`
|
||||
Preferred PreferredCriteria `json:"preferred"`
|
||||
Fallback []FallbackCriteria `json:"fallback"`
|
||||
StabilityScore int `json:"stability_score"`
|
||||
Evidence Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Criteria struct {
|
||||
AutomationID string `json:"automation_id,omitempty"`
|
||||
ControlType string `json:"control_type,omitempty"`
|
||||
FrameworkID string `json:"framework_id,omitempty"`
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
ClassPrefix string `json:"class_name_prefix,omitempty"`
|
||||
AncestorID string `json:"ancestor_selector,omitempty"`
|
||||
}
|
||||
|
||||
type PreferredCriteria struct {
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
ClassPrefix string `json:"class_name_prefix,omitempty"`
|
||||
PathHint string `json:"path_hint,omitempty"`
|
||||
AncestorID string `json:"ancestor_selector,omitempty"`
|
||||
}
|
||||
|
||||
type FallbackCriteria struct {
|
||||
AncestorID string `json:"ancestor_selector,omitempty"`
|
||||
ControlType string `json:"control_type,omitempty"`
|
||||
ClassPrefix string `json:"class_name_prefix,omitempty"`
|
||||
ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"`
|
||||
ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
CaptureID string `json:"capture_id"`
|
||||
UIAPath string `json:"uia_path"`
|
||||
}
|
||||
|
||||
func DefaultCatalog() []Selector {
|
||||
return []Selector{
|
||||
{
|
||||
ID: "main_window",
|
||||
Description: "iSphere / IMPlatformClient 主窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
|
||||
Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
|
||||
StabilityScore: 95,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
|
||||
},
|
||||
{
|
||||
ID: "main_plugin_tab",
|
||||
Description: "主功能 tab 容器",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
|
||||
StabilityScore: 90,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
|
||||
},
|
||||
{
|
||||
ID: "home_page_window",
|
||||
Description: "首页内容窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
|
||||
StabilityScore: 82,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "tab_page_content",
|
||||
Description: "tab 页内容窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
|
||||
StabilityScore: 82,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
|
||||
},
|
||||
{
|
||||
ID: "left_panel",
|
||||
Description: "左侧主面板",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
|
||||
StabilityScore: 92,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
|
||||
},
|
||||
{
|
||||
ID: "roster_panel",
|
||||
Description: "组织/联系人容器外层",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
|
||||
StabilityScore: 88,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "roster_view",
|
||||
Description: "组织/联系人列表区域",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
|
||||
StabilityScore: 86,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "fuzzy_query_panel",
|
||||
Description: "模糊搜索组件外层",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
|
||||
StabilityScore: 90,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
|
||||
},
|
||||
{
|
||||
ID: "fuzzy_query_window",
|
||||
Description: "模糊搜索内部窗口",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
|
||||
StabilityScore: 88,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
|
||||
},
|
||||
{
|
||||
ID: "fuzzy_search_edit",
|
||||
Description: "模糊搜索输入框",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
|
||||
Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
|
||||
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
|
||||
StabilityScore: 93,
|
||||
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run catalog tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit catalog**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\catalog.go internal\uiaselector\catalog_test.go
|
||||
git commit -m "feat: add N13 UIA selector catalog"
|
||||
```
|
||||
|
||||
## Task 3: Load redacted UIA dump fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/dump_test.go`
|
||||
- Create: `internal/uiaselector/dump.go`
|
||||
|
||||
- [ ] **Step 1: Write failing dump loading tests**
|
||||
|
||||
Create `internal/uiaselector/dump_test.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
if root.AutomationID != "frmMain" {
|
||||
t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
|
||||
}
|
||||
if root.ControlType != "Window" {
|
||||
t.Fatalf("root control_type = %q, want Window", root.ControlType)
|
||||
}
|
||||
if got := CountNodes(root); got != 66 {
|
||||
t.Fatalf("CountNodes = %d, want 66", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run dump tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `LoadDumpFile` and `CountNodes` do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement dump loading**
|
||||
|
||||
Create `internal/uiaselector/dump.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type DumpResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Data *DumpData `json:"data"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
|
||||
type DumpData struct {
|
||||
Root *Node `json:"root"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Name string `json:"name"`
|
||||
ControlType string `json:"control_type"`
|
||||
AutomationID string `json:"automation_id"`
|
||||
ClassName string `json:"class_name"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
IsOffscreen *bool `json:"is_offscreen"`
|
||||
Bounds any `json:"bounds"`
|
||||
Children []Node `json:"children"`
|
||||
}
|
||||
|
||||
func LoadDumpFile(path string) (*Node, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response DumpResponse
|
||||
if err := json.Unmarshal(content, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !response.OK {
|
||||
return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
|
||||
}
|
||||
if response.Data == nil || response.Data.Root == nil {
|
||||
return nil, fmt.Errorf("dump response has no data.root")
|
||||
}
|
||||
return response.Data.Root, nil
|
||||
}
|
||||
|
||||
func CountNodes(root *Node) int {
|
||||
if root == nil {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
for i := range root.Children {
|
||||
count += CountNodes(&root.Children[i])
|
||||
}
|
||||
return count
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run dump tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit dump loader**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\dump.go internal\uiaselector\dump_test.go
|
||||
git commit -m "feat: load N13 UIA dump fixture"
|
||||
```
|
||||
|
||||
## Task 4: Implement read-only selector matcher
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/uiaselector/matcher_test.go`
|
||||
- Create: `internal/uiaselector/matcher.go`
|
||||
|
||||
- [ ] **Step 1: Write failing matcher tests**
|
||||
|
||||
Create `internal/uiaselector/matcher_test.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
selectorID string
|
||||
path string
|
||||
control string
|
||||
autoID string
|
||||
}{
|
||||
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
|
||||
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
|
||||
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
|
||||
if !result.OK {
|
||||
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
|
||||
}
|
||||
if !result.Matched {
|
||||
t.Fatalf("%s matched=false", tt.selectorID)
|
||||
}
|
||||
if result.MatchCount != 1 {
|
||||
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
|
||||
}
|
||||
match := result.Matches[0]
|
||||
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
|
||||
t.Fatalf("%s match = %#v", tt.selectorID, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
result := MatchSelector(root, DefaultCatalog(), "main_window")
|
||||
if !result.OK || !result.Matched || len(result.Matches) != 1 {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
match := result.Matches[0]
|
||||
if match.Name != "" {
|
||||
t.Fatalf("match returned visible name text %q", match.Name)
|
||||
}
|
||||
if !match.HasName {
|
||||
t.Fatalf("main_window should report has_name=true")
|
||||
}
|
||||
if match.NameLength == 0 {
|
||||
t.Fatalf("main_window should report non-zero name_length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
|
||||
if result.OK {
|
||||
t.Fatalf("OK=true for missing selector: %#v", result)
|
||||
}
|
||||
if result.Error.Code != "UNKNOWN_SELECTOR" {
|
||||
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run matcher tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestMatchSelector -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `MatchSelector` and result types do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement matcher**
|
||||
|
||||
Create `internal/uiaselector/matcher.go`:
|
||||
|
||||
```go
|
||||
package uiaselector
|
||||
|
||||
import "strings"
|
||||
|
||||
type MatchResult struct {
|
||||
OK bool `json:"ok"`
|
||||
SelectorID string `json:"selector_id"`
|
||||
Matched bool `json:"matched"`
|
||||
MatchCount int `json:"match_count"`
|
||||
Matches []NodeMatch `json:"matches"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Error MatchError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MatchError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type NodeMatch struct {
|
||||
Path string `json:"path"`
|
||||
ControlType string `json:"control_type"`
|
||||
AutomationID string `json:"automation_id"`
|
||||
ClassName string `json:"class_name"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
HasName bool `json:"has_name"`
|
||||
NameLength int `json:"name_length"`
|
||||
Name string `json:"-"`
|
||||
}
|
||||
|
||||
type nodeRef struct {
|
||||
path string
|
||||
node *Node
|
||||
}
|
||||
|
||||
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
|
||||
selector, ok := findSelector(catalog, selectorID)
|
||||
if !ok {
|
||||
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
|
||||
}
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
|
||||
}
|
||||
refs := flatten(root)
|
||||
matches := matchCriteria(refs, selector.Required)
|
||||
if len(matches) == 0 {
|
||||
for _, fallback := range selector.Fallback {
|
||||
matches = matchFallback(refs, fallback)
|
||||
if len(matches) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
|
||||
for _, ref := range matches {
|
||||
result.Matches = append(result.Matches, summarizeNode(ref))
|
||||
}
|
||||
if !result.Matched {
|
||||
result.Warnings = append(result.Warnings, "selector_not_found")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
|
||||
for _, selector := range catalog {
|
||||
if selector.ID == selectorID {
|
||||
return selector, true
|
||||
}
|
||||
}
|
||||
return Selector{}, false
|
||||
}
|
||||
|
||||
func flatten(root *Node) []nodeRef {
|
||||
refs := []nodeRef{}
|
||||
var walk func(node *Node, path string)
|
||||
walk = func(node *Node, path string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
refs = append(refs, nodeRef{path: path, node: node})
|
||||
for i := range node.Children {
|
||||
walk(&node.Children[i], path+"/"+itoa(i))
|
||||
}
|
||||
}
|
||||
walk(root, "root")
|
||||
return refs
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value = value / 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
|
||||
matches := []nodeRef{}
|
||||
for _, ref := range refs {
|
||||
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
|
||||
continue
|
||||
}
|
||||
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
|
||||
continue
|
||||
}
|
||||
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
|
||||
continue
|
||||
}
|
||||
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
|
||||
continue
|
||||
}
|
||||
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
|
||||
matches := []nodeRef{}
|
||||
for _, ref := range refs {
|
||||
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
|
||||
continue
|
||||
}
|
||||
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
|
||||
continue
|
||||
}
|
||||
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
|
||||
continue
|
||||
}
|
||||
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func hasChildAutomationIDs(node *Node, ids []string) bool {
|
||||
for _, id := range ids {
|
||||
found := false
|
||||
for i := range node.Children {
|
||||
if node.Children[i].AutomationID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasChildControlTypes(node *Node, controlTypes []string) bool {
|
||||
for _, controlType := range controlTypes {
|
||||
found := false
|
||||
for i := range node.Children {
|
||||
if node.Children[i].ControlType == controlType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func summarizeNode(ref nodeRef) NodeMatch {
|
||||
return NodeMatch{
|
||||
Path: ref.path,
|
||||
ControlType: ref.node.ControlType,
|
||||
AutomationID: ref.node.AutomationID,
|
||||
ClassName: ref.node.ClassName,
|
||||
FrameworkID: ref.node.FrameworkID,
|
||||
HasName: ref.node.Name != "",
|
||||
NameLength: len(ref.node.Name),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run matcher tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run TestMatchSelector -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit matcher**
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\matcher.go internal\uiaselector\matcher_test.go
|
||||
git commit -m "feat: match N13 UIA selectors offline"
|
||||
```
|
||||
|
||||
## Task 5: Add verification script
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/verify-n13-uia-selectors.ps1`
|
||||
|
||||
- [ ] **Step 1: Create verification script**
|
||||
|
||||
Create `scripts/verify-n13-uia-selectors.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"
|
||||
go test ./internal/uiaselector -count=1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "## boundary grep"
|
||||
$forbidden = @(
|
||||
"send_after_approval",
|
||||
"send_file_after_approval",
|
||||
"receive_file_after_approval",
|
||||
"open_conversation",
|
||||
"ValuePattern.SetValue",
|
||||
"InvokePattern"
|
||||
)
|
||||
$files = @(
|
||||
"internal\uiaselector\catalog.go",
|
||||
"internal\uiaselector\matcher.go"
|
||||
)
|
||||
foreach ($term in $forbidden) {
|
||||
$hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
|
||||
if ($hit) {
|
||||
throw "forbidden action term found in selector implementation: $term"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "N13 UIA selector verification passed."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run script and verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
Expected in Go-enabled environment:
|
||||
|
||||
```text
|
||||
## go test ./internal/uiaselector
|
||||
ok isphere-ai-bridge/internal/uiaselector
|
||||
## boundary grep
|
||||
N13 UIA selector verification passed.
|
||||
```
|
||||
|
||||
If `go` is unavailable, expected failure is:
|
||||
|
||||
```text
|
||||
go was not found. Run this verifier in a Go 1.23.x environment.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit verification script**
|
||||
|
||||
```powershell
|
||||
git add scripts\verify-n13-uia-selectors.ps1
|
||||
git commit -m "test: add N13 selector verifier"
|
||||
```
|
||||
|
||||
## Task 6: Final verification and checkpoint
|
||||
|
||||
**Files:**
|
||||
- No new files.
|
||||
- Verify: `internal/uiaselector/*`, `scripts/verify-n13-uia-selectors.ps1`
|
||||
|
||||
- [ ] **Step 1: Run package tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -count=1
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
ok isphere-ai-bridge/internal/uiaselector
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run repository tests if Go is available**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS for all packages.
|
||||
|
||||
If the environment lacks Go, do not mark implementation complete. Move to a Go 1.23.x environment and rerun.
|
||||
|
||||
- [ ] **Step 3: Run N13 verifier**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
Expected: PASS in Go-enabled environment.
|
||||
|
||||
- [ ] **Step 4: Check no MCP/helper action surface changed**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --name-only origin/main..HEAD
|
||||
```
|
||||
|
||||
Expected changed implementation files are limited to:
|
||||
|
||||
```text
|
||||
internal/uiaselector/catalog.go
|
||||
internal/uiaselector/catalog_test.go
|
||||
internal/uiaselector/dump.go
|
||||
internal/uiaselector/dump_test.go
|
||||
internal/uiaselector/matcher.go
|
||||
internal/uiaselector/matcher_test.go
|
||||
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
|
||||
scripts/verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
If `native/ISphereWinHelper/Program.cs`, `internal/tools/winhelper.go`, or `cmd/isphere-mcp/main.go` appears, stop and review because N13 is not supposed to add live tools.
|
||||
|
||||
- [ ] **Step 5: Commit final checkpoint if needed**
|
||||
|
||||
If any verification-only documentation changes were made, run:
|
||||
|
||||
```powershell
|
||||
git add <changed verification docs>
|
||||
git commit -m "docs: record N13 selector verification"
|
||||
```
|
||||
|
||||
If no files changed, do not create an empty commit.
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
- Spec coverage: this plan covers selector catalog, fixture loading, read-only matcher, no visible text in result, and boundary verification.
|
||||
- Boundary: no helper op, no MCP tool, no click/input/send/file/conversation behavior.
|
||||
- TDD: every implementation task starts with failing tests before code.
|
||||
- Commit cadence: fixture, catalog, loader, matcher, verifier are separate commits.
|
||||
- Environment caveat: Go 1.23.x is required for implementation verification.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
115
docs/superpowers/plans/2026-07-11-a-route-rpa-send.md
Normal file
115
docs/superpowers/plans/2026-07-11-a-route-rpa-send.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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 方案:通过受控 UI Automation 路线让 `isphere_send_message` 能配置为真实 UI 写入并点击发送。
|
||||
|
||||
**Architecture:** Go MCP 继续保留稳定 `isphere_send_message` 接口、hash、idempotency、audit。新增 A-route connector 调用 C# `ISphereWinHelper` 的 UIA action op;C# 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`.
|
||||
- Active branch: `codex/a-route-rpa-send`.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 why direct offline construction of the real `frmP2PChat` is still blocked by login/runtime singletons.
|
||||
@@ -0,0 +1,597 @@
|
||||
# N13 UIA Selector Catalog Design
|
||||
|
||||
日期:2026-07-09
|
||||
|
||||
## 1. 目标
|
||||
|
||||
N13 的目标是基于已 ACCEPT 的 N12R live UIA 证据,设计一套只读 selector catalog 和定位验证流程。
|
||||
|
||||
本阶段只做设计,不新增 helper op,不新增 MCP tool,不点击、不输入、不打开会话、不发送消息、不传输文件。
|
||||
|
||||
N13 完成后,下一步才可以写独立实现计划,考虑是否新增只读定位验证能力。
|
||||
|
||||
## 2. 输入证据
|
||||
|
||||
N12R 返回包已通过外网 validator:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture-reports/2026-07-09-retry-2/2026-07-08-internal-sandbox-a-validation.md
|
||||
Decision: ACCEPT
|
||||
PASS: 36
|
||||
WARN: 1
|
||||
FAIL: 0
|
||||
```
|
||||
|
||||
使用的 UIA dump:
|
||||
|
||||
```text
|
||||
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main.json
|
||||
```
|
||||
|
||||
关键事实:
|
||||
|
||||
```text
|
||||
root control_type: Window
|
||||
root automation_id: frmMain
|
||||
root class_name: WindowsForms10.Window.8.app.0.d3a00f_r29_ad1
|
||||
framework_id: WinForm
|
||||
UIA node count: 66
|
||||
max_depth: 7
|
||||
```
|
||||
|
||||
本设计只引用结构性字段,不依赖敏感可见文本。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
N13 不做以下事项:
|
||||
|
||||
```text
|
||||
自动登录
|
||||
搜索联系人
|
||||
打开会话
|
||||
点击控件
|
||||
输入文本
|
||||
发送消息
|
||||
发送文件
|
||||
接收文件
|
||||
读取或导出聊天内容
|
||||
注入进程
|
||||
hook
|
||||
读取目标进程内存
|
||||
绕过终端安全/安防
|
||||
```
|
||||
|
||||
N13 也不把 `search_contacts`、`open_conversation`、`read_latest_messages` 作为当前实现目标。这些只能在 selector catalog 稳定、只读定位验证通过、并形成新的审批设计后再讨论。
|
||||
|
||||
## 4. 推荐方案
|
||||
|
||||
采用“selector catalog + 只读定位验证”方案。
|
||||
|
||||
核心思想:
|
||||
|
||||
1. 用 N12R UIA dump 建立一份 selector catalog。
|
||||
2. 每个 selector 都只描述如何识别一个 UIA 节点。
|
||||
3. selector 的初始用途只允许 read-only locate。
|
||||
4. 后续如果实现 `locate_uia`,也只能返回匹配节点摘要,不执行任何 UI 动作。
|
||||
5. 任何真实动作类能力必须另写设计、计划和审批边界。
|
||||
|
||||
## 5. Selector 数据模型
|
||||
|
||||
建议 selector 条目使用以下字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "main_window",
|
||||
"description": "主窗口",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "frmMain",
|
||||
"control_type": "Window",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"class_name_prefix": "WindowsForms10.Window.8.app.0",
|
||||
"path_hint": "root"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"control_type": "Window",
|
||||
"class_name_prefix": "WindowsForms10.Window.8.app.0",
|
||||
"child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"]
|
||||
}
|
||||
],
|
||||
"stability_score": 95,
|
||||
"evidence": {
|
||||
"capture_id": "2026-07-08-internal-sandbox-a",
|
||||
"uia_path": "root"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段含义:
|
||||
|
||||
- `id`:稳定、短小、英文蛇形命名。
|
||||
- `description`:中文说明。
|
||||
- `allowed_use`:N13 固定为 `read_only_locate`。
|
||||
- `required`:必须同时满足的字段。
|
||||
- `preferred`:加分项,用于减少误匹配。
|
||||
- `fallback`:主 selector 失效时的只读匹配策略。
|
||||
- `stability_score`:0 到 100,越高越稳定。
|
||||
- `evidence`:来源证据路径和 UIA 路径。
|
||||
|
||||
## 6. 初始 selector catalog
|
||||
|
||||
### 6.1 主窗口:`main_window`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "main_window",
|
||||
"description": "iSphere / IMPlatformClient 主窗口",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "frmMain",
|
||||
"control_type": "Window",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"class_name_prefix": "WindowsForms10.Window.8.app.0",
|
||||
"path_hint": "root"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"control_type": "Window",
|
||||
"class_name_prefix": "WindowsForms10.Window.8.app.0",
|
||||
"child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"]
|
||||
}
|
||||
],
|
||||
"stability_score": 95,
|
||||
"evidence": {
|
||||
"uia_path": "root"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:`automation_id=frmMain` 出现在根节点;root 是 N12R 已确认主窗口,稳定性最高。
|
||||
|
||||
### 6.2 主 tab 容器:`main_plugin_tab`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "main_plugin_tab",
|
||||
"description": "主功能 tab 容器",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "ucPluginTabControl1",
|
||||
"control_type": "Pane",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/3",
|
||||
"ancestor_selector": "main_window"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "main_window",
|
||||
"control_type": "Pane",
|
||||
"child_automation_id_contains": ["FrmHomePage", "ucTabPage"]
|
||||
}
|
||||
],
|
||||
"stability_score": 90,
|
||||
"evidence": {
|
||||
"uia_path": "root/3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点直接挂在主窗口下,automation_id 明确,子结构包含首页和 tab 内容区。
|
||||
|
||||
### 6.3 首页窗口:`home_page_window`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "home_page_window",
|
||||
"description": "首页内容窗口",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "FrmHomePage",
|
||||
"control_type": "Window",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/3/0/0",
|
||||
"ancestor_selector": "main_plugin_tab"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "main_plugin_tab",
|
||||
"control_type": "Window",
|
||||
"class_name_prefix": "WindowsForms10.Window.8.app.0"
|
||||
}
|
||||
],
|
||||
"stability_score": 82,
|
||||
"evidence": {
|
||||
"uia_path": "root/3/0/0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点可用于确认当前主 UI 内容区存在,但不用于动作。
|
||||
|
||||
### 6.4 tab 内容窗口:`tab_page_content`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "tab_page_content",
|
||||
"description": "tab 页内容窗口",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "FrmTabPageContent",
|
||||
"control_type": "Window",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/3/1/0",
|
||||
"ancestor_selector": "main_plugin_tab"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "main_plugin_tab",
|
||||
"control_type": "Window",
|
||||
"child_control_type_contains": ["TitleBar"]
|
||||
}
|
||||
],
|
||||
"stability_score": 82,
|
||||
"evidence": {
|
||||
"uia_path": "root/3/1/0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:可作为后续只读结构检查的内容区域锚点。
|
||||
|
||||
### 6.5 左侧面板:`left_panel`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "left_panel",
|
||||
"description": "左侧主面板",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "panelLeft",
|
||||
"control_type": "Pane",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/4",
|
||||
"ancestor_selector": "main_window"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "main_window",
|
||||
"control_type": "Pane",
|
||||
"child_automation_id_contains": ["ucPnlRoster1", "ucFuzzyQuery1"]
|
||||
}
|
||||
],
|
||||
"stability_score": 92,
|
||||
"evidence": {
|
||||
"uia_path": "root/4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点是联系人/搜索相关区域的稳定父节点。
|
||||
|
||||
### 6.6 组织/联系人容器:`roster_panel`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "roster_panel",
|
||||
"description": "组织/联系人容器外层",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "ucPnlRoster1",
|
||||
"control_type": "Pane",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/4/0/0",
|
||||
"ancestor_selector": "left_panel"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "left_panel",
|
||||
"control_type": "Pane",
|
||||
"child_automation_id_contains": ["ucRoster1"]
|
||||
}
|
||||
],
|
||||
"stability_score": 88,
|
||||
"evidence": {
|
||||
"uia_path": "root/4/0/0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点是 roster 结构外层,可以只读验证联系人区域是否存在。
|
||||
|
||||
### 6.7 组织/联系人列表:`roster_view`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "roster_view",
|
||||
"description": "组织/联系人列表区域",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "ucRoster1",
|
||||
"control_type": "Pane",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/4/0/0/0",
|
||||
"ancestor_selector": "roster_panel"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "roster_panel",
|
||||
"control_type": "Pane",
|
||||
"class_name_prefix": "WindowsForms10.Window.8.app.0"
|
||||
}
|
||||
],
|
||||
"stability_score": 86,
|
||||
"evidence": {
|
||||
"uia_path": "root/4/0/0/0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点本次 child_count 为 0,不能证明列表项结构;只能作为区域存在性 selector。
|
||||
|
||||
### 6.8 搜索组件外层:`fuzzy_query_panel`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "fuzzy_query_panel",
|
||||
"description": "模糊搜索组件外层",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "ucFuzzyQuery1",
|
||||
"control_type": "Pane",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/4/1/0",
|
||||
"ancestor_selector": "left_panel"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "left_panel",
|
||||
"control_type": "Pane",
|
||||
"child_automation_id_contains": ["FrmFuzzyQuery"]
|
||||
}
|
||||
],
|
||||
"stability_score": 90,
|
||||
"evidence": {
|
||||
"uia_path": "root/4/1/0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该组件是搜索输入框的稳定父级。
|
||||
|
||||
### 6.9 搜索窗口:`fuzzy_query_window`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "fuzzy_query_window",
|
||||
"description": "模糊搜索内部窗口",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "FrmFuzzyQuery",
|
||||
"control_type": "Pane",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"path_hint": "root/4/1/0/0",
|
||||
"ancestor_selector": "fuzzy_query_panel"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "fuzzy_query_panel",
|
||||
"control_type": "Pane",
|
||||
"child_automation_id_contains": ["skinAlphaTxt"]
|
||||
}
|
||||
],
|
||||
"stability_score": 88,
|
||||
"evidence": {
|
||||
"uia_path": "root/4/1/0/0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点包含搜索输入框,是后续只读定位验证中的关键父节点。
|
||||
|
||||
### 6.10 搜索输入框:`fuzzy_search_edit`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "fuzzy_search_edit",
|
||||
"description": "模糊搜索输入框",
|
||||
"allowed_use": "read_only_locate",
|
||||
"required": {
|
||||
"automation_id": "skinAlphaTxt",
|
||||
"control_type": "Edit",
|
||||
"framework_id": "WinForm"
|
||||
},
|
||||
"preferred": {
|
||||
"class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1",
|
||||
"path_hint": "root/4/1/0/0/2",
|
||||
"ancestor_selector": "fuzzy_query_window"
|
||||
},
|
||||
"fallback": [
|
||||
{
|
||||
"ancestor_selector": "fuzzy_query_window",
|
||||
"control_type": "Edit",
|
||||
"class_name_prefix": "WindowsForms10.EDIT.app.0"
|
||||
}
|
||||
],
|
||||
"stability_score": 93,
|
||||
"evidence": {
|
||||
"uia_path": "root/4/1/0/0/2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
理由:该节点 automation_id、control_type 和 class_name 都明确。N13 只允许定位它,不允许输入搜索词。
|
||||
|
||||
## 7. 只读定位验证规则
|
||||
|
||||
后续如果实现定位验证,只能做以下事情:
|
||||
|
||||
1. 接收 selector id。
|
||||
2. 读取当前窗口 UIA 树。
|
||||
3. 按 selector required 字段过滤节点。
|
||||
4. 用 preferred 字段排序或加分。
|
||||
5. 如果主 selector 无匹配,再尝试 fallback。
|
||||
6. 返回节点摘要:`selector_id`、`matched`、`match_count`、`path`、`control_type`、`automation_id`、`class_name`、`framework_id`、`is_enabled`、`is_offscreen`、`bounds`。
|
||||
|
||||
禁止返回完整 visible text。可见文本只能返回以下派生信息:
|
||||
|
||||
```text
|
||||
has_name: true/false
|
||||
name_length: number
|
||||
```
|
||||
|
||||
禁止执行:
|
||||
|
||||
```text
|
||||
InvokePattern
|
||||
ValuePattern.SetValue
|
||||
SelectionItemPattern.Select
|
||||
ExpandCollapsePattern.Expand
|
||||
点击
|
||||
键盘输入
|
||||
剪贴板操作
|
||||
窗口激活
|
||||
焦点切换
|
||||
```
|
||||
|
||||
## 8. 稳定性评分规则
|
||||
|
||||
建议评分:
|
||||
|
||||
```text
|
||||
95-100: root 或唯一主窗口级 anchor
|
||||
90-94: automation_id 明确、control_type 明确、父子路径稳定
|
||||
80-89: automation_id 明确,但可能依赖 tab 或运行状态
|
||||
70-79: 缺 automation_id,但 class/control/path 组合可用
|
||||
0-69: 不进入首批 catalog
|
||||
```
|
||||
|
||||
本次首批 selector 全部要求 automation_id 明确。
|
||||
|
||||
## 9. 错误处理设计
|
||||
|
||||
只读定位验证应返回结构化结果,不抛出模糊错误。
|
||||
|
||||
建议结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"selector_id": "fuzzy_search_edit",
|
||||
"matched": true,
|
||||
"match_count": 1,
|
||||
"matches": [
|
||||
{
|
||||
"path": "root/4/1/0/0/2",
|
||||
"control_type": "Edit",
|
||||
"automation_id": "skinAlphaTxt",
|
||||
"class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1",
|
||||
"framework_id": "WinForm",
|
||||
"has_name": true,
|
||||
"name_length": 0
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
未匹配时:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"selector_id": "fuzzy_search_edit",
|
||||
"matched": false,
|
||||
"match_count": 0,
|
||||
"matches": [],
|
||||
"warnings": ["selector_not_found"]
|
||||
}
|
||||
```
|
||||
|
||||
selector 定义非法时:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"selector_id": "fuzzy_search_edit",
|
||||
"error": {
|
||||
"code": "INVALID_SELECTOR",
|
||||
"message": "selector required fields are incomplete"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
N13 设计阶段不写实现代码,但后续实现计划必须包含以下测试:
|
||||
|
||||
1. 从 N12R dump fixture 读取 UIA 树。
|
||||
2. 验证 `main_window` 匹配唯一 root。
|
||||
3. 验证 `left_panel` 匹配 `root/4`。
|
||||
4. 验证 `fuzzy_search_edit` 匹配 `root/4/1/0/0/2`。
|
||||
5. 验证 selector 不返回完整 `name` 文本,只返回 `has_name` 和 `name_length`。
|
||||
6. 验证缺失 selector 返回 `matched=false`,不报崩溃。
|
||||
7. 验证动作模式字段或动作请求被拒绝。
|
||||
8. 验证 catalog 中不包含 `send`、`upload`、`download`、`conversation`、`login` 等动作词作为工具名。
|
||||
|
||||
## 11. 文件规划
|
||||
|
||||
如果后续进入实现计划,建议新增或修改:
|
||||
|
||||
```text
|
||||
internal/uiaselector/catalog.go
|
||||
internal/uiaselector/catalog_test.go
|
||||
internal/uiaselector/matcher.go
|
||||
internal/uiaselector/matcher_test.go
|
||||
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
|
||||
scripts/verify-n13-uia-selectors.ps1
|
||||
```
|
||||
|
||||
暂不修改:
|
||||
|
||||
```text
|
||||
native/ISphereWinHelper/Program.cs
|
||||
internal/tools/winhelper.go
|
||||
cmd/isphere-mcp/main.go
|
||||
```
|
||||
|
||||
除非新的实现计划明确批准新增只读定位工具,否则 Go MCP 仍只暴露现有四个只读工具。
|
||||
|
||||
## 12. N13 验收条件
|
||||
|
||||
N13 设计可接受条件:
|
||||
|
||||
1. selector catalog 只依赖结构性 UIA 字段。
|
||||
2. 不依赖敏感可见文本。
|
||||
3. 首批 selector 都能追溯到 N12R ACCEPT dump 的路径。
|
||||
4. allowed_use 全部是 `read_only_locate`。
|
||||
5. 文档明确禁止点击、输入、打开会话、发送和文件操作。
|
||||
6. 后续实现计划必须 TDD,并先使用 fixture 测试 selector matcher。
|
||||
|
||||
## 13. 推荐下一步
|
||||
|
||||
下一步不是直接写代码,而是写一份 N13 implementation plan。
|
||||
|
||||
该计划应先实现纯离线 selector matcher,对 N12R UIA dump fixture 做只读匹配测试。
|
||||
|
||||
只有离线 selector matcher 通过后,才讨论是否新增 live `locate_uia` helper/MCP 只读工具。
|
||||
@@ -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 报告模型和 CLI,PowerShell 只做包装
|
||||
|
||||
做法:在 `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 失败。
|
||||
|
||||
非严格模式下,未命中或歧义只写入报告和 warnings,CLI 仍返回 `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. 跑全量验证并提交。
|
||||
@@ -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. 风险与缓解
|
||||
|
||||
### 风险 1:PowerShell 对单元素数组展开造成遍历差异
|
||||
|
||||
缓解:实现遍历时使用 `@($report.selectors)` 和 `@($selector.matches)`,确保单元素和多元素都按数组处理。
|
||||
|
||||
### 风险 2:allowlist 检查误报 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。
|
||||
113
internal/isphere/contacts.go
Normal file
113
internal/isphere/contacts.go
Normal 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
|
||||
}
|
||||
38
internal/isphere/contacts_test.go
Normal file
38
internal/isphere/contacts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
89
internal/isphere/file_source.go
Normal file
89
internal/isphere/file_source.go
Normal 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
|
||||
}
|
||||
57
internal/isphere/file_source_test.go
Normal file
57
internal/isphere/file_source_test.go
Normal 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
142
internal/isphere/files.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
31
internal/isphere/files_test.go
Normal file
31
internal/isphere/files_test.go
Normal 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
119
internal/isphere/groups.go
Normal 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
|
||||
}
|
||||
39
internal/isphere/groups_test.go
Normal file
39
internal/isphere/groups_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
internal/isphere/logcodec/codec.go
Normal file
52
internal/isphere/logcodec/codec.go
Normal 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
|
||||
}
|
||||
43
internal/isphere/logcodec/codec_test.go
Normal file
43
internal/isphere/logcodec/codec_test.go
Normal 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
|
||||
}
|
||||
104
internal/isphere/message_reconciliation.go
Normal file
104
internal/isphere/message_reconciliation.go
Normal 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
|
||||
}
|
||||
57
internal/isphere/message_reconciliation_test.go
Normal file
57
internal/isphere/message_reconciliation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
98
internal/isphere/msglib_source.go
Normal file
98
internal/isphere/msglib_source.go
Normal 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 ""
|
||||
}
|
||||
98
internal/isphere/msglib_source_test.go
Normal file
98
internal/isphere/msglib_source_test.go
Normal 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
|
||||
}
|
||||
73
internal/isphere/packetlog/message.go
Normal file
73
internal/isphere/packetlog/message.go
Normal 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
|
||||
}
|
||||
27
internal/isphere/packetlog/message_test.go
Normal file
27
internal/isphere/packetlog/message_test.go
Normal 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
181
internal/isphere/source.go
Normal 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
|
||||
}
|
||||
158
internal/isphere/source_test.go
Normal file
158
internal/isphere/source_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
405
internal/msglib/sidecar_client.go
Normal file
405
internal/msglib/sidecar_client.go
Normal 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)
|
||||
}
|
||||
430
internal/msglib/sidecar_client_test.go
Normal file
430
internal/msglib/sidecar_client_test.go
Normal 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
|
||||
}
|
||||
188
internal/tools/display_entities.go
Normal file
188
internal/tools/display_entities.go
Normal 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
|
||||
}
|
||||
88
internal/tools/isphere_contacts.go
Normal file
88
internal/tools/isphere_contacts.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
239
internal/tools/isphere_contacts_test.go
Normal file
239
internal/tools/isphere_contacts_test.go
Normal 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
|
||||
}
|
||||
216
internal/tools/isphere_files.go
Normal file
216
internal/tools/isphere_files.go
Normal 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
|
||||
}
|
||||
286
internal/tools/isphere_files_test.go
Normal file
286
internal/tools/isphere_files_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
87
internal/tools/isphere_groups.go
Normal file
87
internal/tools/isphere_groups.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
235
internal/tools/isphere_groups_test.go
Normal file
235
internal/tools/isphere_groups_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
342
internal/tools/isphere_read.go
Normal file
342
internal/tools/isphere_read.go
Normal 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
|
||||
}
|
||||
389
internal/tools/isphere_read_test.go
Normal file
389
internal/tools/isphere_read_test.go
Normal 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
|
||||
}
|
||||
542
internal/tools/isphere_send_file.go
Normal file
542
internal/tools/isphere_send_file.go
Normal 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
|
||||
}
|
||||
}
|
||||
351
internal/tools/isphere_send_file_test.go
Normal file
351
internal/tools/isphere_send_file_test.go
Normal 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
|
||||
}
|
||||
595
internal/tools/isphere_send_message.go
Normal file
595
internal/tools/isphere_send_message.go
Normal 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
|
||||
}
|
||||
722
internal/tools/isphere_send_message_test.go
Normal file
722
internal/tools/isphere_send_message_test.go
Normal 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)
|
||||
}
|
||||
81
internal/tools/send_message_broute_adapter.go
Normal file
81
internal/tools/send_message_broute_adapter.go
Normal 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user