From 1f9968134881c52722d6e2e2b499c6582fc3001f Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Fri, 10 Jul 2026 13:37:08 +0800 Subject: [PATCH] feat: add send connector preflight probes --- docs/current-status-card.md | 16 +- docs/go-csharp-helper-boundary.md | 118 ++- .../2026-07-10-live-probe-recorder.md | 6 + .../2026-07-10-send-connector-preflight.md | 127 +++ .../2026-07-10-send-sidecar-b-first-plan.md | 18 +- .../capability-source-matrix.md | 7 +- ...7-10-core-business-capabilities-roadmap.md | 44 +- native/ISphereWinHelper/LiveProbeRecorder.cs | 56 +- native/ISphereWinHelper/Program.cs | 8 +- .../SendConnectorPreflight.cs | 848 ++++++++++++++++++ scripts/verify-win-helper.ps1 | 84 +- 11 files changed, 1307 insertions(+), 25 deletions(-) create mode 100644 docs/source-discovery/2026-07-10-send-connector-preflight.md create mode 100644 native/ISphereWinHelper/SendConnectorPreflight.cs diff --git a/docs/current-status-card.md b/docs/current-status-card.md index 523a6fd..846feba 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -23,7 +23,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor - `isphere_search_contacts` - `isphere_search_groups` - `isphere_receive_files` -- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract. +- 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, eight-tool MCP surface, default empty-source receive/contact/group/file calls, 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`. @@ -38,7 +38,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor - The project has not yet implemented business MCP tools for message sending, file download, or 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. +- The project has not implemented real message sending or real file upload. R6a confirms metadata/control preflight 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. @@ -56,7 +56,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `R5b returned live-probe evidence intake complete; next R6a/C31 non-mutating send connector preflight`. +Current loop: `R6a/C31 non-mutating send connector preflight complete; next R6b B-route preview/dry-run contract`. Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`. @@ -111,6 +111,7 @@ Current loop output so far: 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. ## Recompiled business roadmap @@ -162,3 +163,12 @@ R5b returned live-probe evidence intake is complete: - 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. diff --git a/docs/go-csharp-helper-boundary.md b/docs/go-csharp-helper-boundary.md index 0c2a1ed..0d696a0 100644 --- a/docs/go-csharp-helper-boundary.md +++ b/docs/go-csharp-helper-boundary.md @@ -160,7 +160,7 @@ VERIFY_FAILED ## 6. 当前只读 helper op -当前 helper 只支持只读探测类 op。`version`、`self_check`、`scan_windows`、`dump_uia` 是第一阶段窗口/UIA 基线;`probe_client_runtime` 是 C30 为 B 路线发送连接器新增的只读运行时探测。 +当前 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` @@ -182,7 +182,7 @@ VERIFY_FAILED ```json { "helper_name": "ISphereWinHelper", - "helper_version": "0.3.0", + "helper_version": "0.4.0", "protocol": "isphere.helper.v1", "runtime": ".NET Framework" } @@ -334,6 +334,120 @@ C# helper 文件输出通过未来 `out_path` 参数控制。 该 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 扩展顺序 必须等真实登录窗口的 UIA dump 被确认后,再扩展真实动作。 diff --git a/docs/source-discovery/2026-07-10-live-probe-recorder.md b/docs/source-discovery/2026-07-10-live-probe-recorder.md index beeae64..04b6978 100644 --- a/docs/source-discovery/2026-07-10-live-probe-recorder.md +++ b/docs/source-discovery/2026-07-10-live-probe-recorder.md @@ -77,6 +77,8 @@ 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 @@ -97,6 +99,8 @@ The important evidence is: - 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; @@ -120,3 +124,5 @@ The recorder does not: 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. diff --git a/docs/source-discovery/2026-07-10-send-connector-preflight.md b/docs/source-discovery/2026-07-10-send-connector-preflight.md new file mode 100644 index 0000000..28694c7 --- /dev/null +++ b/docs/source-discovery/2026-07-10-send-connector-preflight.md @@ -0,0 +1,127 @@ +# 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 + +Next node: **R6b B-route preview/dry-run contract**. + +R6b should implement only preview/dry-run and audit shape first: + +1. target resolution input shape from existing contact/group search result refs; +2. `content_sha256` / file hash and idempotency key validation; +3. connector metadata such as `connector="implatform-sidecar"` and `connector_stage="preview"`; +4. local audit append for preview attempts; +5. no real send, no file upload, and no UI click/type. + +The later online-only gate remains: one operator-approved sandbox send must still prove success/ack or sent-record evidence before any production send path is exposed. diff --git a/docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md b/docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md index aec74f6..8872c24 100644 --- a/docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md +++ b/docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md @@ -344,16 +344,18 @@ connector_stage = "preview" | "sandbox_send" | "production" ## Next implementation loop -R5b/C30-live returned-probe intake is now complete. The live probe confirmed a running x86 `IMPlatformClient.exe` `4.1.2.6842`, installed B-route candidate DLLs, and A-route UIA controls, but it did not prove runtime send entrypoint reachability. It also observed an offline-send blocker in the captured chat windows. +R6a/C31 non-mutating send connector preflight is now complete. WinHelper `0.4.0` adds read-only `probe_send_entrypoints` and `probe_send_uia_controls`; the live recorder now emits `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`; copied install metadata reports all required B-route entrypoints available; and the UIA classifier can report offline-blocked state. -Next node should be **R6a/C31 non-mutating send connector preflight**: +Next node should be **R6b B-route preview/dry-run contract**: -1. Use the C# WinHelper read-only op `probe_client_runtime`. -2. Add or run `probe_send_entrypoints` against copied/installed binaries to confirm `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and file-upload/file-message candidates by metadata only. -3. Add or run a UIA preflight classifier for `skinAlphaTxt`, `rtbSendMessage`, `btnSend`, `发送文件`, and offline-send message state. -4. Do not send, click, type, upload, attach hooks, or capture network traffic in this node. -5. Update `capability-source-matrix.md` after B2/A-route preflight passes or fails. +1. Keep `isphere_send_message` in preview/dry-run only. +2. Resolve target refs from existing contact/group search output. +3. Validate `content_sha256` and `idempotency_key`. +4. Return connector metadata such as `connector="implatform-sidecar"` and `connector_stage="preview"`. +5. Append a local audit event for preview attempts. +6. Do not send, click, type, upload, attach hooks, or capture network traffic in this node. +7. Keep the later online-only sandbox-send gate separate. ## Current status -B is selected as the primary send/file-send route. The first non-mutating runtime probe has now been run against a real logged-in environment through the returned live-probe package. B can continue only through non-mutating entrypoint confirmation; A remains the fallback and now has concrete UIA selector evidence, but production send remains blocked until dry-run/idempotency/audit and sandbox-send evidence exist. +B is selected as the primary send/file-send route. Runtime discovery and entrypoint metadata preflight have passed enough to continue to preview/dry-run design. A remains the fallback and now has concrete UIA selector/offline-blocker classifier support. Production send remains blocked until dynamic observation, idempotent dry-run/audit, and one approved sandbox send prove success/ack behavior. diff --git a/docs/source-discovery/capability-source-matrix.md b/docs/source-discovery/capability-source-matrix.md index a319ead..25a85dd 100644 --- a/docs/source-discovery/capability-source-matrix.md +++ b/docs/source-discovery/capability-source-matrix.md @@ -5,6 +5,7 @@ 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` ## Source priority @@ -23,8 +24,8 @@ Returned live probe: `docs/source-discovery/2026-07-10-returned-live-probe-analy | `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\` 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` | C22 verifies safe file-list contract args; R3 confirms list metadata is available; R3b diagnostic finds 0 current cache/archive matches, so R4 remains blocked | blocked pending better cache evidence or UI/client download connector | -| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path | 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` | R5b returned live probe confirms an x86 running `IMPlatformClient.exe` `4.1.2.6842`, live TCP endpoint, installed B-route DLL presence, and A-route UIA controls (`skinAlphaTxt`, `rtbSendMessage`, `btnSend`, file menu). It also shows the chat window reporting an offline-send blocker, and it does not prove runtime send entrypoint reachability or success/ack mapping. | next: R6a/C31 non-mutating send connector preflight: `probe_send_entrypoints` plus UIA offline-state/control classifier; keep production send blocked | -| `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(...)` | 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` | R5b confirms `IMPP.Service*.dll`, `TcpFileTransfer.dll`, and UIA `发送文件` menu evidence in the live install/window tree, but file-send remains blocked behind message-send connector preflight plus upload/file-message dry-run evidence. | next: wait for R6a/C31 message-send preflight; then file-send runtime/upload preflight | +| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path | 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` | R6a/C31 adds read-only `probe_send_entrypoints` and `probe_send_uia_controls`. Copied install metadata reports all 5 required B-route send entrypoints available, including `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and file-transfer candidates. UIA classifier can report offline-blocked state. This is preflight only: no real send, success/ack, or idempotent production behavior is proven. | next: R6b B-route preview/dry-run contract and audit shape; production send remains blocked | +| `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(...)` | 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` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind the message-send preview/dry-run contract plus later upload/file-message dry-run and sandbox evidence. | next: wait for R6b message-send preview/dry-run, then file-send upload preflight | ## Stage C entry rule @@ -41,4 +42,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac ## 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. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6a/C31 non-mutating send connector preflight. +`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. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6b B-route preview/dry-run contract. diff --git a/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md b/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md index 3cd8d13..c1ffb65 100644 --- a/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md +++ b/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md @@ -28,9 +28,9 @@ | 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 | Contract exists; R5b returned live probe confirms B-route runtime environment and A-route UIA controls, but not production send. | Need non-mutating entrypoint preflight, offline-state handling, dry-run/idempotency/audit, and one approved sandbox send before production. | next R6a/C31 non-mutating connector preflight | +| Send messages | Contract exists; R6a/C31 read-only preflight confirms B-route entrypoint metadata and A-route UIA/offline-state classifier support, but not production send. | Need preview/dry-run contract, idempotency/audit, dynamic observation, and one approved sandbox send before production. | next R6b B-route preview/dry-run 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; live probe confirms file-send menu and installed file-transfer DLL candidates. | Blocked behind send-message connector plus upload/file-transfer dry-run evidence. | wait for R6a/C31, then file-send preflight | +| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind send-message preview/dry-run plus upload/file-transfer dry-run and sandbox evidence. | wait for R6b, then file-send upload preflight | ## Node Sequence @@ -401,11 +401,49 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1 **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. + --- ### R6: Send-message preview and production path -**Purpose:** Implement `isphere_send_message` after R6a validates a connector path and a later approved sandbox-send gate proves success/ack behavior. +**Purpose:** Implement production `isphere_send_message` after R6b preview/dry-run exists and a later approved sandbox-send gate proves success/ack behavior. **Files:** - Create or modify: `internal/tools/isphere_send_message.go` diff --git a/native/ISphereWinHelper/LiveProbeRecorder.cs b/native/ISphereWinHelper/LiveProbeRecorder.cs index d2683d5..67443b6 100644 --- a/native/ISphereWinHelper/LiveProbeRecorder.cs +++ b/native/ISphereWinHelper/LiveProbeRecorder.cs @@ -47,17 +47,23 @@ namespace ISphereWinHelper Dictionary filesystem = LiveProbeInventory.CollectFilesystem(runtime); WriteJson(Path.Combine(outputDir, "filesystem_inventory.json"), filesystem); + Dictionary sendEntrypoints = SendConnectorPreflight.ProbeSendEntrypoints(new Dictionary()); + WriteJson(Path.Combine(outputDir, "send_entrypoints_preflight.json"), sendEntrypoints); + string uiaDir = Path.Combine(outputDir, "uia"); Directory.CreateDirectory(uiaDir); List> uiaSummary = DumpTargetWindowUia(runtime, windows, uiaDir); WriteJson(Path.Combine(outputDir, "uia_summary.json"), uiaSummary); + List> sendUiaPreflight = ProbeTargetWindowSendControls(runtime, windows); + WriteJson(Path.Combine(outputDir, "send_uia_controls_preflight.json"), sendUiaPreflight); + Dictionary feasibility = LiveProbeInventory.SummarizeFeasibility(runtime, filesystem, network); WriteJson(Path.Combine(outputDir, "feasibility_summary.json"), feasibility); WriteText(Path.Combine(outputDir, "NEXT_STEP.txt"), "把整个目录压缩后发回即可。\r\n" + - "重点文件:probe_client_runtime.json、process_details.json、network_inventory.json、filesystem_inventory.json、services_registry_shortcuts.json、scan_windows.json、uia_summary.json、uia/*.json、feasibility_summary.json\r\n"); + "重点文件:probe_client_runtime.json、send_entrypoints_preflight.json、send_uia_controls_preflight.json、process_details.json、network_inventory.json、filesystem_inventory.json、services_registry_shortcuts.json、scan_windows.json、uia_summary.json、uia/*.json、feasibility_summary.json\r\n"); Console.WriteLine("OK"); Console.WriteLine("已生成采集目录:"); @@ -77,6 +83,50 @@ namespace ISphereWinHelper } } + private static List> ProbeTargetWindowSendControls(Dictionary runtime, Dictionary windows) + { + var targetPids = TargetPids(runtime); + var summary = new List>(); + object rawWindows; + if (!windows.TryGetValue("windows", out rawWindows)) + { + return summary; + } + + var windowList = rawWindows as List>; + if (windowList == null) + { + return summary; + } + + foreach (Dictionary window in windowList) + { + int pid = GetInt(window, "pid"); + if (!targetPids.Contains(pid)) + { + continue; + } + + string hwnd = GetString(window, "hwnd"); + string title = GetString(window, "title"); + Dictionary probe = SendConnectorPreflight.ProbeSendUiaControls("live-probe-send-uia", "probe_send_uia_controls", new Dictionary + { + { "hwnd", hwnd }, + { "max_depth", 8 }, + { "max_children", 180 } + }); + summary.Add(new Dictionary + { + { "pid", pid }, + { "hwnd", hwnd }, + { "title_present", !string.IsNullOrEmpty(title) }, + { "ok", GetBool(probe, "ok") }, + { "probe", probe } + }); + } + return summary; + } + private static List> DumpTargetWindowUia(Dictionary runtime, Dictionary windows, string uiaDir) { var targetPids = TargetPids(runtime); @@ -154,7 +204,7 @@ namespace ISphereWinHelper return new Dictionary { { "recorder_name", "ISphereLiveProbeRecorder" }, - { "recorder_version", "0.2.0" }, + { "recorder_version", "0.3.0" }, { "helper_protocol", HelperProtocol.Protocol }, { "run_id", runId }, { "timestamp_local", DateTime.Now.ToString("o") }, @@ -183,7 +233,7 @@ namespace ISphereWinHelper "5. 双击 ISphereLiveProbeRecorder.exe。\r\n" + "6. 程序会在 probe-output 下生成 isphere-live-probe-时间戳 目录。\r\n" + "7. 把整个目录压缩后发回。\r\n\r\n" + - "采集内容:运行进程、模块、命令行摘要、窗口、目标窗口 UIA 控件树、安装目录关键文件、配置/日志/数据库候选、注册表卸载项、快捷方式、服务、本地 TCP 连接、命名管道。\r\n" + + "采集内容:运行进程、模块、命令行摘要、窗口、目标窗口 UIA 控件树、发送入口元数据预检、发送控件/离线状态预检、安装目录关键文件、配置/日志/数据库候选、注册表卸载项、快捷方式、服务、本地 TCP 连接、命名管道。\r\n" + "不会执行:发送消息、发送文件、上传文件、点击、输入、注入、hook、修改客户端数据。\r\n"; } diff --git a/native/ISphereWinHelper/Program.cs b/native/ISphereWinHelper/Program.cs index 6e02261..7d1c7fe 100644 --- a/native/ISphereWinHelper/Program.cs +++ b/native/ISphereWinHelper/Program.cs @@ -47,6 +47,12 @@ namespace ISphereWinHelper case "probe_client_runtime": response = HelperProtocol.Success(requestId, op, RuntimeProbe.ProbeClientRuntime(opArgs)); break; + case "probe_send_entrypoints": + response = HelperProtocol.Success(requestId, op, SendConnectorPreflight.ProbeSendEntrypoints(opArgs)); + break; + case "probe_send_uia_controls": + response = SendConnectorPreflight.ProbeSendUiaControls(requestId, op, opArgs); + break; default: response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op); break; @@ -82,7 +88,7 @@ namespace ISphereWinHelper return new Dictionary { { "helper_name", "ISphereWinHelper" }, - { "helper_version", "0.3.0" }, + { "helper_version", "0.4.0" }, { "protocol", HelperProtocol.Protocol }, { "runtime", ".NET Framework " + Environment.Version } }; diff --git a/native/ISphereWinHelper/SendConnectorPreflight.cs b/native/ISphereWinHelper/SendConnectorPreflight.cs new file mode 100644 index 0000000..0743839 --- /dev/null +++ b/native/ISphereWinHelper/SendConnectorPreflight.cs @@ -0,0 +1,848 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Windows.Automation; + +namespace ISphereWinHelper +{ + internal static class SendConnectorPreflight + { + private sealed class EntrypointSpec + { + public readonly string Id; + public readonly string Capability; + public readonly string AssemblyName; + public readonly string TypeName; + public readonly string MethodName; + public readonly bool Required; + + public EntrypointSpec(string id, string capability, string assemblyName, string typeName, string methodName, bool required) + { + Id = id; + Capability = capability; + AssemblyName = assemblyName; + TypeName = typeName; + MethodName = methodName; + Required = required; + } + } + + private sealed class UiaMatch + { + public string Path = ""; + public string ControlType = ""; + public string AutomationId = ""; + public string ClassName = ""; + public string SafeName = ""; + public bool IsEnabled; + public bool IsOffscreen; + public Dictionary Bounds = new Dictionary(); + + public Dictionary ToDictionary() + { + return new Dictionary + { + { "path", Path }, + { "control_type", ControlType }, + { "automation_id", AutomationId }, + { "class_name", ClassName }, + { "safe_name", SafeName }, + { "is_enabled", IsEnabled }, + { "is_offscreen", IsOffscreen }, + { "bounds", Bounds } + }; + } + } + + private static readonly EntrypointSpec[] DefaultEntrypoints = new[] + { + new EntrypointSpec( + "text_high_level_by_jid", + "send_message", + "IMPlatformClient.exe", + "IMPP.Client.Core.Plugins.Manager.AppContextManager", + "SendTxtMessageByJid", + true), + new EntrypointSpec( + "text_message_scheduler", + "send_message", + "IMPlatformClient.exe", + "IMPP.Client.Core.MessageEngine.MessageScheduling", + "SendChatMessage", + true), + new EntrypointSpec( + "text_wire_chat_send", + "send_message", + "smack.dll", + "com.vision.smack.Chat", + "SendMessage", + true), + new EntrypointSpec( + "file_service_upload", + "send_file", + "IMPP.Service.dll", + "IMPP.Service.BLL.FileTransfer", + "FileUpload", + true), + new EntrypointSpec( + "file_service_upload_async", + "send_file", + "IMPP.Service.dll", + "IMPP.Service.BLL.FileTransfer", + "AsynFileUpload", + false), + new EntrypointSpec( + "file_offline_send", + "send_file", + "IMPlatformClient.exe", + "IMPP.Client.OffLineFileSend", + "sendFile", + true), + new EntrypointSpec( + "file_wire_message", + "send_file", + "smack.dll", + "com.vision.smack.Chat", + "sendFileMessage", + false) + }; + + public static Dictionary ProbeSendEntrypoints(Dictionary args) + { + var installDirs = CandidateInstallDirs(args); + var assemblyPaths = ResolveAssemblyPaths(installDirs); + var assemblySummaries = SummarizeAssemblies(assemblyPaths); + var entrypoints = new List>(); + + int requiredCount = 0; + int requiredAvailable = 0; + int availableCount = 0; + + ResolveEventHandler resolveHandler = delegate(object sender, ResolveEventArgs eventArgs) + { + return ResolveReflectionOnlyAssembly(eventArgs, installDirs); + }; + + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += resolveHandler; + try + { + foreach (EntrypointSpec spec in DefaultEntrypoints) + { + if (spec.Required) + { + requiredCount++; + } + + Dictionary result = ProbeEntrypoint(spec, assemblyPaths); + bool available = GetBool(result, "available"); + if (available) + { + availableCount++; + if (spec.Required) + { + requiredAvailable++; + } + } + entrypoints.Add(result); + } + } + finally + { + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve -= resolveHandler; + } + + return new Dictionary + { + { "probe_mode", "read_only_send_entrypoint_metadata" }, + { "safety", SafetyData() }, + { "install_dirs_checked", installDirs }, + { "assemblies", assemblySummaries }, + { "entrypoints", entrypoints }, + { "summary", new Dictionary + { + { "entrypoint_count", entrypoints.Count }, + { "available_count", availableCount }, + { "required_count", requiredCount }, + { "required_available_count", requiredAvailable }, + { "all_required_available", requiredCount > 0 && requiredAvailable == requiredCount }, + { "b_route_entrypoint_preflight", requiredCount > 0 && requiredAvailable == requiredCount ? "pass" : "incomplete" } + } + } + }; + } + + public static Dictionary ProbeSendUiaControls(string requestId, string op, Dictionary args) + { + string hwndText = HelperProtocol.GetString(args, "hwnd", ""); + IntPtr hwnd; + if (!WindowScanner.TryParseHwnd(hwndText, out hwnd)) + { + return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "invalid or empty hwnd"); + } + + int maxDepth = HelperProtocol.GetInt(args, "max_depth", 8); + if (maxDepth < 0) maxDepth = 0; + if (maxDepth > 20) maxDepth = 20; + int maxChildren = HelperProtocol.GetInt(args, "max_children", 200); + if (maxChildren < 1) maxChildren = 1; + if (maxChildren > 1000) maxChildren = 1000; + + try + { + AutomationElement root = AutomationElement.FromHandle(hwnd); + if (root == null) + { + return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "no UI Automation element for hwnd"); + } + + Dictionary data = ClassifyUiaTree(hwnd, root, maxDepth, maxChildren); + return HelperProtocol.Success(requestId, op, data); + } + catch (Exception ex) + { + return HelperProtocol.Failure(requestId, op, "UIA_SEND_PREFLIGHT_FAILED", ex.Message); + } + } + + private static Dictionary ClassifyUiaTree(IntPtr hwnd, AutomationElement root, int maxDepth, int maxChildren) + { + UiaMatch searchEdit = null; + UiaMatch receiveDocument = null; + UiaMatch sendEditor = null; + UiaMatch sendButton = null; + UiaMatch fileMenu = null; + UiaMatch offlineBlocker = null; + int visited = 0; + bool truncated = false; + + Traverse(root, "root", 0, maxDepth, maxChildren, ref visited, ref truncated, delegate(AutomationElement element, string path) + { + string automationId = SafeString(delegate { return element.Current.AutomationId; }); + string name = SafeString(delegate { return element.Current.Name; }); + string value = SafeValue(element); + string controlType = SafeControlType(element); + string combined = (automationId + " " + name + " " + value).ToLowerInvariant(); + + if (searchEdit == null && + (StringEquals(automationId, "skinAlphaTxt") || + name.IndexOf("按帐号、姓名、拼音、标签查询联系人", StringComparison.OrdinalIgnoreCase) >= 0)) + { + searchEdit = MatchFrom(element, path, "按帐号、姓名、拼音、标签查询联系人"); + } + if (receiveDocument == null && StringEquals(automationId, "rtbRecvMessage")) + { + receiveDocument = MatchFrom(element, path, ""); + } + if (sendEditor == null && StringEquals(automationId, "rtbSendMessage")) + { + sendEditor = MatchFrom(element, path, ""); + } + if (sendButton == null && + (StringEquals(automationId, "btnSend") || name.IndexOf("发送(S)", StringComparison.OrdinalIgnoreCase) >= 0)) + { + sendButton = MatchFrom(element, path, "发送(S)"); + } + if (fileMenu == null && + (StringEquals(automationId, "btnFile") || + StringEquals(automationId, "btnSendFile") || + name.IndexOf("发送文件", StringComparison.OrdinalIgnoreCase) >= 0)) + { + fileMenu = MatchFrom(element, path, "发送文件"); + } + if (offlineBlocker == null && + (StringEquals(automationId, "offlineSendBlocker") || + combined.IndexOf("离线状态", StringComparison.OrdinalIgnoreCase) >= 0 || + combined.IndexOf("无法发送消息", StringComparison.OrdinalIgnoreCase) >= 0)) + { + offlineBlocker = MatchFrom(element, path, "您已处于离线状态,无法发送消息,请上线后再次尝试!"); + } + }); + + bool hasSearchEdit = searchEdit != null; + bool hasSendEditor = sendEditor != null; + bool hasSendButton = sendButton != null; + bool hasFileMenu = fileMenu != null; + bool hasOfflineBlocker = offlineBlocker != null; + bool sendButtonEnabled = sendButton != null && sendButton.IsEnabled && !sendButton.IsOffscreen; + string routeHint = hasOfflineBlocker ? "A_ROUTE_OFFLINE_BLOCKED" : + (hasSendEditor && hasSendButton ? "A_ROUTE_CONTROLS_PRESENT" : "A_ROUTE_INCOMPLETE"); + + return new Dictionary + { + { "probe_mode", "read_only_uia_send_control_classifier" }, + { "safety", SafetyData() }, + { "hwnd", WindowScanner.FormatHwnd(hwnd) }, + { "root", new Dictionary + { + { "automation_id", SafeString(delegate { return root.Current.AutomationId; }) }, + { "control_type", SafeControlType(root) }, + { "class_name", SafeString(delegate { return root.Current.ClassName; }) }, + { "framework_id", SafeString(delegate { return root.Current.FrameworkId; }) }, + { "name_present", !string.IsNullOrEmpty(SafeString(delegate { return root.Current.Name; })) } + } + }, + { "flags", new Dictionary + { + { "has_search_edit", hasSearchEdit }, + { "has_receive_document", receiveDocument != null }, + { "has_send_editor", hasSendEditor }, + { "has_send_button", hasSendButton }, + { "has_file_menu", hasFileMenu }, + { "offline_blocker_visible", hasOfflineBlocker }, + { "send_button_enabled", sendButtonEnabled }, + { "route_hint", routeHint }, + { "visited_nodes", visited }, + { "truncated", truncated } + } + }, + { "controls", new Dictionary + { + { "search_edit", MatchOrNull(searchEdit) }, + { "receive_document", MatchOrNull(receiveDocument) }, + { "send_editor", MatchOrNull(sendEditor) }, + { "send_button", MatchOrNull(sendButton) }, + { "file_menu", MatchOrNull(fileMenu) }, + { "offline_blocker", MatchOrNull(offlineBlocker) } + } + } + }; + } + + private delegate void ElementVisitor(AutomationElement element, string path); + + private static void Traverse(AutomationElement element, string path, int depth, int maxDepth, int maxChildren, ref int visited, ref bool truncated, ElementVisitor visitor) + { + if (element == null) + { + return; + } + visited++; + visitor(element, path); + if (depth >= maxDepth) + { + return; + } + + AutomationElementCollection children; + try + { + children = element.FindAll(TreeScope.Children, Condition.TrueCondition); + } + catch + { + return; + } + + int limit = Math.Min(children.Count, maxChildren); + if (children.Count > limit) + { + truncated = true; + } + for (int i = 0; i < limit; i++) + { + Traverse(children[i], path + "/" + i.ToString(CultureInfo.InvariantCulture), depth + 1, maxDepth, maxChildren, ref visited, ref truncated, visitor); + } + } + + private static UiaMatch MatchFrom(AutomationElement element, string path, string safeName) + { + return new UiaMatch + { + Path = path, + ControlType = SafeControlType(element), + AutomationId = SafeString(delegate { return element.Current.AutomationId; }), + ClassName = SafeString(delegate { return element.Current.ClassName; }), + SafeName = safeName, + IsEnabled = SafeBool(delegate { return element.Current.IsEnabled; }), + IsOffscreen = SafeBool(delegate { return element.Current.IsOffscreen; }), + Bounds = SafeBounds(element) + }; + } + + private static object MatchOrNull(UiaMatch match) + { + if (match == null) + { + return null; + } + return match.ToDictionary(); + } + + private static Dictionary ProbeEntrypoint(EntrypointSpec spec, Dictionary assemblyPaths) + { + string assemblyPath = ""; + assemblyPaths.TryGetValue(spec.AssemblyName, out assemblyPath); + bool assemblyFound = !string.IsNullOrEmpty(assemblyPath) && File.Exists(assemblyPath); + bool reflectionTypeFound = false; + bool reflectionMethodFound = false; + string reflectionError = ""; + var signatures = new List(); + + if (assemblyFound) + { + try + { + Assembly assembly = Assembly.ReflectionOnlyLoadFrom(assemblyPath); + Type targetType = FindType(assembly, spec.TypeName); + reflectionTypeFound = targetType != null; + if (targetType != null) + { + MethodInfo[] methods = targetType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance); + foreach (MethodInfo method in methods) + { + if (!StringEquals(method.Name, spec.MethodName)) + { + continue; + } + reflectionMethodFound = true; + if (signatures.Count < 8) + { + signatures.Add(MethodSignature(method)); + } + } + } + } + catch (Exception ex) + { + reflectionError = ex.GetType().Name + ": " + ex.Message; + } + } + + bool stringTypeHint = false; + bool stringFullTypeHint = false; + bool stringShortTypeHint = false; + bool stringMethodHint = false; + string stringError = ""; + if (assemblyFound) + { + try + { + byte[] bytes = File.ReadAllBytes(assemblyPath); + string ascii = Encoding.ASCII.GetString(bytes); + string unicode = Encoding.Unicode.GetString(bytes); + string shortTypeName = ShortTypeName(spec.TypeName); + stringFullTypeHint = ascii.IndexOf(spec.TypeName, StringComparison.OrdinalIgnoreCase) >= 0 || + unicode.IndexOf(spec.TypeName, StringComparison.OrdinalIgnoreCase) >= 0 || + ascii.IndexOf(spec.TypeName.Replace(".", "/"), StringComparison.OrdinalIgnoreCase) >= 0; + stringShortTypeHint = !string.IsNullOrEmpty(shortTypeName) && + (ascii.IndexOf(shortTypeName, StringComparison.OrdinalIgnoreCase) >= 0 || + unicode.IndexOf(shortTypeName, StringComparison.OrdinalIgnoreCase) >= 0); + stringTypeHint = stringFullTypeHint || stringShortTypeHint; + stringMethodHint = ascii.IndexOf(spec.MethodName, StringComparison.OrdinalIgnoreCase) >= 0 || + unicode.IndexOf(spec.MethodName, StringComparison.OrdinalIgnoreCase) >= 0; + } + catch (Exception ex) + { + stringError = ex.GetType().Name + ": " + ex.Message; + } + } + + bool available = assemblyFound && ((reflectionTypeFound && reflectionMethodFound) || (stringTypeHint && stringMethodHint)); + return new Dictionary + { + { "id", spec.Id }, + { "capability", spec.Capability }, + { "assembly", spec.AssemblyName }, + { "assembly_found", assemblyFound }, + { "assembly_path_present", assemblyFound }, + { "type_name", spec.TypeName }, + { "method_name", spec.MethodName }, + { "required", spec.Required }, + { "reflection_type_found", reflectionTypeFound }, + { "reflection_method_found", reflectionMethodFound }, + { "reflection_error", reflectionError }, + { "string_type_hint", stringTypeHint }, + { "string_full_type_hint", stringFullTypeHint }, + { "string_short_type_hint", stringShortTypeHint }, + { "string_method_hint", stringMethodHint }, + { "string_scan_error", stringError }, + { "available", available }, + { "signatures", signatures } + }; + } + + private static Type FindType(Assembly assembly, string typeName) + { + try + { + Type exact = assembly.GetType(typeName, false, true); + if (exact != null) + { + return exact; + } + } + catch + { + } + + try + { + foreach (Type type in assembly.GetTypes()) + { + if (StringEquals(type.FullName, typeName) || StringEquals(type.Name, typeName)) + { + return type; + } + } + } + catch (ReflectionTypeLoadException ex) + { + foreach (Type type in ex.Types) + { + if (type != null && (StringEquals(type.FullName, typeName) || StringEquals(type.Name, typeName))) + { + return type; + } + } + } + catch + { + } + return null; + } + + private static string ShortTypeName(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + { + return ""; + } + int dot = typeName.LastIndexOf('.'); + int plus = typeName.LastIndexOf('+'); + int slash = typeName.LastIndexOf('/'); + int cut = Math.Max(dot, Math.Max(plus, slash)); + return cut >= 0 && cut + 1 < typeName.Length ? typeName.Substring(cut + 1) : typeName; + } + + private static string MethodSignature(MethodInfo method) + { + var sb = new StringBuilder(); + sb.Append(method.IsStatic ? "static " : "instance "); + sb.Append(method.Name); + sb.Append("("); + ParameterInfo[] parameters = method.GetParameters(); + for (int i = 0; i < parameters.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(SafeTypeName(parameters[i].ParameterType)); + } + sb.Append(") -> "); + sb.Append(SafeTypeName(method.ReturnType)); + return sb.ToString(); + } + + private static string SafeTypeName(Type type) + { + try + { + return type.FullName ?? type.Name; + } + catch + { + return ""; + } + } + + private static Assembly ResolveReflectionOnlyAssembly(ResolveEventArgs args, List installDirs) + { + try + { + string simpleName = new AssemblyName(args.Name).Name + ".dll"; + foreach (string dir in installDirs) + { + string candidate = Path.Combine(dir, simpleName); + if (File.Exists(candidate)) + { + return Assembly.ReflectionOnlyLoadFrom(candidate); + } + } + } + catch + { + } + return null; + } + + private static List CandidateInstallDirs(Dictionary args) + { + var dirs = new List(); + string explicitDir = HelperProtocol.GetString(args, "install_dir", ""); + AddDir(dirs, explicitDir); + + foreach (string item in GetStringList(args, "install_dirs")) + { + AddDir(dirs, item); + } + + foreach (Process process in Process.GetProcessesByName("IMPlatformClient")) + { + using (process) + { + try + { + if (process.MainModule != null) + { + AddDir(dirs, Path.GetDirectoryName(process.MainModule.FileName)); + } + } + catch + { + } + } + } + + AddDir(dirs, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Impp")); + AddDir(dirs, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Impp")); + return dirs; + } + + private static void AddDir(List dirs, string dir) + { + if (string.IsNullOrWhiteSpace(dir)) + { + return; + } + try + { + string full = Path.GetFullPath(dir); + foreach (string existing in dirs) + { + if (StringEquals(existing, full)) + { + return; + } + } + dirs.Add(full); + } + catch + { + } + } + + private static Dictionary ResolveAssemblyPaths(List dirs) + { + var paths = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (EntrypointSpec spec in DefaultEntrypoints) + { + if (!paths.ContainsKey(spec.AssemblyName)) + { + paths.Add(spec.AssemblyName, ""); + } + } + + foreach (string dir in dirs) + { + foreach (EntrypointSpec spec in DefaultEntrypoints) + { + if (!string.IsNullOrEmpty(paths[spec.AssemblyName])) + { + continue; + } + string candidate = Path.Combine(dir, spec.AssemblyName); + if (File.Exists(candidate)) + { + paths[spec.AssemblyName] = candidate; + } + } + } + return paths; + } + + private static List> SummarizeAssemblies(Dictionary paths) + { + var list = new List>(); + foreach (KeyValuePair item in paths) + { + bool exists = !string.IsNullOrEmpty(item.Value) && File.Exists(item.Value); + string version = ""; + long length = 0; + string sha256 = ""; + if (exists) + { + try + { + var info = new FileInfo(item.Value); + length = info.Length; + FileVersionInfo vi = FileVersionInfo.GetVersionInfo(item.Value); + version = vi == null ? "" : (vi.FileVersion ?? ""); + sha256 = Sha256(item.Value); + } + catch + { + } + } + list.Add(new Dictionary + { + { "name", item.Key }, + { "found", exists }, + { "path_present", exists }, + { "length", length }, + { "file_version", version }, + { "sha256", sha256 } + }); + } + return list; + } + + private static Dictionary SafetyData() + { + return new Dictionary + { + { "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 } + }; + } + + private static List GetStringList(Dictionary source, string key) + { + var values = new List(); + object raw; + if (source != null && source.TryGetValue(key, out raw) && raw != null) + { + object[] rawArray = raw as object[]; + if (rawArray != null) + { + foreach (object item in rawArray) + { + string value = Convert.ToString(item); + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value.Trim()); + } + } + return values; + } + string single = Convert.ToString(raw); + if (!string.IsNullOrWhiteSpace(single)) + { + foreach (string part in single.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + string value = part.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value); + } + } + } + } + return values; + } + + private delegate string StringGetter(); + private delegate bool BoolGetter(); + + private static string SafeString(StringGetter getter) + { + try { return getter() ?? ""; } + catch { return ""; } + } + + private static bool SafeBool(BoolGetter getter) + { + try { return getter(); } + catch { return false; } + } + + private static string SafeControlType(AutomationElement element) + { + try + { + string name = element.Current.ControlType.ProgrammaticName ?? ""; + return name.StartsWith("ControlType.", StringComparison.Ordinal) ? name.Substring("ControlType.".Length) : name; + } + catch + { + return ""; + } + } + + private static Dictionary SafeBounds(AutomationElement element) + { + try + { + System.Windows.Rect rect = element.Current.BoundingRectangle; + return new Dictionary + { + { "x", rect.X }, + { "y", rect.Y }, + { "width", rect.Width }, + { "height", rect.Height } + }; + } + catch + { + return new Dictionary + { + { "x", 0 }, + { "y", 0 }, + { "width", 0 }, + { "height", 0 } + }; + } + } + + private static string SafeValue(AutomationElement element) + { + try + { + object pattern; + if (element.TryGetCurrentPattern(ValuePattern.Pattern, out pattern)) + { + return ((ValuePattern)pattern).Current.Value ?? ""; + } + } + catch + { + } + return ""; + } + + private static bool StringEquals(string left, string right) + { + return string.Equals(left ?? "", right ?? "", StringComparison.OrdinalIgnoreCase); + } + + private static bool GetBool(Dictionary source, string key) + { + object value; + if (source.TryGetValue(key, out value) && value != null) + { + try { return Convert.ToBoolean(value); } catch { } + } + return false; + } + + private static string Sha256(string path) + { + try + { + using (SHA256 sha = SHA256.Create()) + using (FileStream stream = File.OpenRead(path)) + { + byte[] hash = sha.ComputeHash(stream); + var sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + return sb.ToString(); + } + } + catch + { + return ""; + } + } + } +} diff --git a/scripts/verify-win-helper.ps1 b/scripts/verify-win-helper.ps1 index fe6b2cd..06cd747 100644 --- a/scripts/verify-win-helper.ps1 +++ b/scripts/verify-win-helper.ps1 @@ -80,6 +80,20 @@ if (-not $runtimeProbe.ok -or $runtimeProbe.data.probe_mode -ne "read_only_proce throw "probe_client_runtime failed: $($runtimeProbe | ConvertTo-Json -Depth 8 -Compress)" } +$entrypointProbe = Invoke-HelperJson @{ + protocol = "isphere.helper.v1" + request_id = "verify-send-entrypoints" + op = "probe_send_entrypoints" + timeout_ms = 5000 + args = @{} +} +if (-not $entrypointProbe.ok -or $entrypointProbe.data.probe_mode -ne "read_only_send_entrypoint_metadata" -or $null -eq $entrypointProbe.data.entrypoints) { + throw "probe_send_entrypoints failed: $($entrypointProbe | ConvertTo-Json -Depth 8 -Compress)" +} +if ($entrypointProbe.data.safety.sent_message -or $entrypointProbe.data.safety.uploaded_file -or $entrypointProbe.data.safety.clicked_ui) { + throw "probe_send_entrypoints safety flags failed: $($entrypointProbe | ConvertTo-Json -Depth 8 -Compress)" +} + $dumpMissing = Invoke-HelperJson @{ protocol = "isphere.helper.v1" request_id = "verify-dump-missing" @@ -92,8 +106,52 @@ if ($dumpMissing.ok -or $dumpMissing.error.code -notin @("WINDOW_NOT_FOUND", "UI } $title = "Codex WinHelper Verify " + [guid]::NewGuid().ToString("N").Substring(0, 8) -$formScript = "Add-Type -AssemblyName System.Windows.Forms; `$form = New-Object System.Windows.Forms.Form; `$form.Text = '$title'; `$form.Width = 420; `$form.Height = 180; `$button = New-Object System.Windows.Forms.Button; `$button.Text = 'ProbeButton'; `$button.Dock = 'Fill'; `$form.Controls.Add(`$button); [void]`$form.ShowDialog()" -$process = Start-Process powershell -ArgumentList @("-NoProfile", "-Command", $formScript) -PassThru +$formScriptPath = Join-Path $env:TEMP ("codex-winhelper-form-" + [guid]::NewGuid().ToString("N") + ".ps1") +$formScript = @" +Add-Type -AssemblyName System.Windows.Forms +`$form = New-Object System.Windows.Forms.Form +`$form.Text = '$title' +`$form.Width = 620 +`$form.Height = 420 +`$layout = New-Object System.Windows.Forms.TableLayoutPanel +`$layout.Dock = 'Fill' +`$layout.RowCount = 6 +`$layout.ColumnCount = 1 +`$form.Controls.Add(`$layout) +`$search = New-Object System.Windows.Forms.TextBox +`$search.Name = 'skinAlphaTxt' +`$search.Text = 'search placeholder' +`$search.Dock = 'Fill' +`$layout.Controls.Add(`$search) +`$recv = New-Object System.Windows.Forms.RichTextBox +`$recv.Name = 'rtbRecvMessage' +`$recv.Text = '' +`$recv.Dock = 'Fill' +`$layout.Controls.Add(`$recv) +`$send = New-Object System.Windows.Forms.RichTextBox +`$send.Name = 'rtbSendMessage' +`$send.Text = '' +`$send.Dock = 'Fill' +`$layout.Controls.Add(`$send) +`$button = New-Object System.Windows.Forms.Button +`$button.Name = 'btnSend' +`$button.Text = 'Send' +`$button.Dock = 'Fill' +`$layout.Controls.Add(`$button) +`$file = New-Object System.Windows.Forms.Button +`$file.Name = 'btnFile' +`$file.Text = 'File' +`$file.Dock = 'Fill' +`$layout.Controls.Add(`$file) +`$offline = New-Object System.Windows.Forms.Label +`$offline.Name = 'offlineSendBlocker' +`$offline.Text = 'Offline send blocker' +`$offline.Dock = 'Fill' +`$layout.Controls.Add(`$offline) +[void]`$form.ShowDialog() +"@ +Set-Content -LiteralPath $formScriptPath -Value $formScript -Encoding UTF8 +$process = Start-Process powershell -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $formScriptPath) -PassThru try { $candidate = $null for ($i = 0; $i -lt 20 -and -not $candidate; $i++) { @@ -121,11 +179,31 @@ try { if (-not $dump.ok -or $dump.data.root.control_type -ne "Window") { throw "dump_uia form check failed: $($dump | ConvertTo-Json -Depth 12 -Compress)" } + + $sendUiaProbe = Invoke-HelperJson @{ + protocol = "isphere.helper.v1" + request_id = "verify-send-uia-controls" + op = "probe_send_uia_controls" + timeout_ms = 5000 + args = @{ hwnd = $candidate.hwnd; max_depth = 6; max_children = 120 } + } + if (-not $sendUiaProbe.ok -or $sendUiaProbe.data.probe_mode -ne "read_only_uia_send_control_classifier") { + throw "probe_send_uia_controls failed: $($sendUiaProbe | ConvertTo-Json -Depth 12 -Compress)" + } + if (-not $sendUiaProbe.data.flags.has_search_edit -or -not $sendUiaProbe.data.flags.has_send_editor -or -not $sendUiaProbe.data.flags.has_send_button -or -not $sendUiaProbe.data.flags.has_file_menu -or -not $sendUiaProbe.data.flags.offline_blocker_visible) { + throw "probe_send_uia_controls missing expected synthetic controls: $($sendUiaProbe | ConvertTo-Json -Depth 12 -Compress)" + } + if ($sendUiaProbe.data.safety.sent_message -or $sendUiaProbe.data.safety.uploaded_file -or $sendUiaProbe.data.safety.clicked_ui -or $sendUiaProbe.data.safety.typed_text) { + throw "probe_send_uia_controls safety flags failed: $($sendUiaProbe | ConvertTo-Json -Depth 12 -Compress)" + } } finally { if ($process -and -not $process.HasExited) { Stop-Process -Id $process.Id -Force } + if (Test-Path -LiteralPath $formScriptPath) { + Remove-Item -LiteralPath $formScriptPath -Force + } } [ordered]@{ @@ -134,5 +212,7 @@ finally { version = $version.data.helper_version scan_window_count = $scan.data.windows.Count runtime_probe_target_count = $runtimeProbe.data.target_count + send_entrypoint_probe_mode = $entrypointProbe.data.probe_mode + synthetic_send_uia_route_hint = $sendUiaProbe.data.flags.route_hint uia_available = $selfCheck.data.uia_available } | ConvertTo-Json -Depth 4 -Compress