feat: add a-route uia send connector
This commit is contained in:
@@ -383,3 +383,16 @@ R14 release-candidate report is complete:
|
|||||||
- Updated `docs\go-mcp-runbook.md` with the R14 business status and package paths.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -574,7 +574,38 @@ runs\send-file-sandbox-gate-package.zip
|
|||||||
|
|
||||||
They are generated under ignored `runs\` and are not committed.
|
They are generated under ignored `runs\` and are not committed.
|
||||||
|
|
||||||
## 18. Troubleshooting
|
## 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 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.
|
- If `win_helper_version` fails, rerun `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1` first.
|
||||||
|
|||||||
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.
|
||||||
95
docs/superpowers/plans/2026-07-11-a-route-rpa-send.md
Normal file
95
docs/superpowers/plans/2026-07-11-a-route-rpa-send.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 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`.
|
||||||
@@ -61,7 +61,7 @@ func NewServerFromEnv() (*mcp.Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return NewServerWithSourcesAndMsgLibReceive(source, displaySource, msglibReceiveSource), nil
|
return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, tools.NewSendMessageConnectorFromEnv()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func msgLibSourcesFromEnv() (tools.DisplayEntitySource, tools.ReceiveMessagesSource, error) {
|
func msgLibSourcesFromEnv() (tools.DisplayEntitySource, tools.ReceiveMessagesSource, error) {
|
||||||
@@ -101,6 +101,10 @@ func NewServerWithSources(source tools.ReceiveMessagesSource, displaySource tool
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource, msglibReceiveSource tools.ReceiveMessagesSource) *mcp.Server {
|
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{
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
Name: ServerName,
|
Name: ServerName,
|
||||||
Title: ServerTitle,
|
Title: ServerTitle,
|
||||||
@@ -111,7 +115,7 @@ func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, di
|
|||||||
tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource)
|
tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource)
|
||||||
tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource)
|
tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource)
|
||||||
tools.RegisterISphereFileTools(server, source)
|
tools.RegisterISphereFileTools(server, source)
|
||||||
tools.RegisterISphereSendMessageTool(server, nil)
|
tools.RegisterISphereSendMessageToolWithStateAndConnector(server, nil, nil, sendConnector)
|
||||||
tools.RegisterISphereSendFileTool(server)
|
tools.RegisterISphereSendFileTool(server)
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/des"
|
"crypto/des"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -283,6 +285,56 @@ func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
type fakeMsgLibClientForServerTest struct {
|
||||||
displayCalls []msglib.DisplayEntitiesOptions
|
displayCalls []msglib.DisplayEntitiesOptions
|
||||||
listCalls []msglib.ListMessagesOptions
|
listCalls []msglib.ListMessagesOptions
|
||||||
@@ -376,3 +428,8 @@ func padPacketLogLineForServerTest(data []byte, blockSize int) []byte {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sha256HexForServerTest(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ type normalizedSendMessageArgs struct {
|
|||||||
TargetType string
|
TargetType string
|
||||||
TargetID string
|
TargetID string
|
||||||
TargetRef string
|
TargetRef string
|
||||||
|
ContentText string
|
||||||
ContentLength int
|
ContentLength int
|
||||||
ContentSHA256 string
|
ContentSHA256 string
|
||||||
IdempotencyKeySHA256 string
|
IdempotencyKeySHA256 string
|
||||||
@@ -308,6 +309,7 @@ func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs,
|
|||||||
TargetType: targetType,
|
TargetType: targetType,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
TargetRef: targetRefPrefix + ":" + targetID,
|
TargetRef: targetRefPrefix + ":" + targetID,
|
||||||
|
ContentText: contentText,
|
||||||
ContentLength: len(contentText),
|
ContentLength: len(contentText),
|
||||||
ContentSHA256: contentHash,
|
ContentSHA256: contentHash,
|
||||||
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
|
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
|
||||||
@@ -448,6 +450,12 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage
|
|||||||
response["send_status"] = normalized.Status
|
response["send_status"] = normalized.Status
|
||||||
response["connector_mode"] = normalized.ConnectorMode
|
response["connector_mode"] = normalized.ConnectorMode
|
||||||
response["connector_stage"] = normalized.Status
|
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 {
|
if normalized.Accepted {
|
||||||
response["blocked_reason"] = nil
|
response["blocked_reason"] = nil
|
||||||
} else if normalized.ErrorMessage != "" {
|
} else if normalized.ErrorMessage != "" {
|
||||||
@@ -471,6 +479,9 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage
|
|||||||
if normalized.AckRef != "" {
|
if normalized.AckRef != "" {
|
||||||
audit["ack_ref"] = normalized.AckRef
|
audit["ack_ref"] = normalized.AckRef
|
||||||
}
|
}
|
||||||
|
if normalized.ProductionEnabled {
|
||||||
|
audit["production_send_enabled"] = true
|
||||||
|
}
|
||||||
if normalized.ErrorCode != "" {
|
if normalized.ErrorCode != "" {
|
||||||
audit["error_code"] = normalized.ErrorCode
|
audit["error_code"] = normalized.ErrorCode
|
||||||
}
|
}
|
||||||
@@ -487,6 +498,9 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage
|
|||||||
event.AckRef = normalized.AckRef
|
event.AckRef = normalized.AckRef
|
||||||
event.ErrorCode = normalized.ErrorCode
|
event.ErrorCode = normalized.ErrorCode
|
||||||
event.ErrorMessage = normalized.ErrorMessage
|
event.ErrorMessage = normalized.ErrorMessage
|
||||||
|
if len(normalized.SideEffects) > 0 {
|
||||||
|
event.SideEffects = normalized.SideEffects
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,52 @@ func TestISphereSendMessageFakeConnectorFailureAudit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
||||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||||
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
|
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
|
||||||
@@ -650,13 +696,15 @@ func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeSendMessageConnector struct {
|
type fakeSendMessageConnector struct {
|
||||||
result SendMessageConnectorResult
|
result SendMessageConnectorResult
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
|
lastRequest SendMessageConnectorRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, _ SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, request SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
|
f.lastRequest = request
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,3 +712,11 @@ func sha256HexForSendMessageTest(value string) string {
|
|||||||
sum := sha256.Sum256([]byte(value))
|
sum := sha256.Sum256([]byte(value))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustMarshalStringForSendMessageTest(value any) string {
|
||||||
|
payload, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type SendMessageConnectorRequest struct {
|
|||||||
TargetType string
|
TargetType string
|
||||||
TargetID string
|
TargetID string
|
||||||
TargetRef string
|
TargetRef string
|
||||||
|
ContentText string
|
||||||
ContentSHA256 string
|
ContentSHA256 string
|
||||||
ContentLength int
|
ContentLength int
|
||||||
IdempotencyKeySHA256 string
|
IdempotencyKeySHA256 string
|
||||||
@@ -20,12 +21,14 @@ type SendMessageConnectorRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SendMessageConnectorResult struct {
|
type SendMessageConnectorResult struct {
|
||||||
Accepted bool
|
Accepted bool
|
||||||
Status string
|
Status string
|
||||||
AckRef string
|
AckRef string
|
||||||
ErrorCode string
|
ErrorCode string
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
ConnectorMode string
|
ConnectorMode string
|
||||||
|
ProductionEnabled bool
|
||||||
|
SideEffects map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest {
|
func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest {
|
||||||
@@ -33,6 +36,7 @@ func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs)
|
|||||||
TargetType: input.TargetType,
|
TargetType: input.TargetType,
|
||||||
TargetID: input.TargetID,
|
TargetID: input.TargetID,
|
||||||
TargetRef: input.TargetRef,
|
TargetRef: input.TargetRef,
|
||||||
|
ContentText: input.ContentText,
|
||||||
ContentSHA256: input.ContentSHA256,
|
ContentSHA256: input.ContentSHA256,
|
||||||
ContentLength: input.ContentLength,
|
ContentLength: input.ContentLength,
|
||||||
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
||||||
|
|||||||
239
internal/tools/send_message_uia_adapter.go
Normal file
239
internal/tools/send_message_uia_adapter.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"isphere-ai-bridge/internal/helperclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
uiaSendMessageOp = "uia_send_message"
|
||||||
|
EnvSendConnectorMode = "ISPHERE_SEND_CONNECTOR_MODE"
|
||||||
|
EnvSendUIAHelperPath = "ISPHERE_SEND_UIA_HELPER_PATH"
|
||||||
|
EnvSendUIAHwnd = "ISPHERE_SEND_UIA_HWND"
|
||||||
|
EnvSendUIAEditorAutomationID = "ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID"
|
||||||
|
EnvSendUIButtonAutomationID = "ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID"
|
||||||
|
EnvSendUIATimeoutSeconds = "ISPHERE_SEND_UIA_TIMEOUT_SECONDS"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UiaSendMessageAdapterConfig struct {
|
||||||
|
HelperPath string
|
||||||
|
TimeoutSeconds int
|
||||||
|
Hwnd string
|
||||||
|
SendEditorAutomationID string
|
||||||
|
SendButtonAutomationID string
|
||||||
|
Mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UiaHelperCaller interface {
|
||||||
|
CallUiaHelper(ctx context.Context, op string, args map[string]any) (uiaHelperResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type uiaHelperResponse struct {
|
||||||
|
OK bool
|
||||||
|
Data map[string]any
|
||||||
|
ErrCode string
|
||||||
|
ErrText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type helperClientUiaCaller struct {
|
||||||
|
client helperclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type uiaSendMessageConnector struct {
|
||||||
|
config UiaSendMessageAdapterConfig
|
||||||
|
caller UiaHelperCaller
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSendMessageConnectorFromEnv() SendMessageConnector {
|
||||||
|
mode := strings.TrimSpace(strings.ToLower(os.Getenv(EnvSendConnectorMode)))
|
||||||
|
if mode != "uia_rpa" && mode != "uia-rpa" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timeoutSeconds := 10
|
||||||
|
if raw := strings.TrimSpace(os.Getenv(EnvSendUIATimeoutSeconds)); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||||
|
timeoutSeconds = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{
|
||||||
|
Mode: "enabled",
|
||||||
|
HelperPath: strings.TrimSpace(os.Getenv(EnvSendUIAHelperPath)),
|
||||||
|
TimeoutSeconds: timeoutSeconds,
|
||||||
|
Hwnd: strings.TrimSpace(os.Getenv(EnvSendUIAHwnd)),
|
||||||
|
SendEditorAutomationID: strings.TrimSpace(os.Getenv(EnvSendUIAEditorAutomationID)),
|
||||||
|
SendButtonAutomationID: strings.TrimSpace(os.Getenv(EnvSendUIButtonAutomationID)),
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUiaSendMessageConnector(config UiaSendMessageAdapterConfig, caller UiaHelperCaller) SendMessageConnector {
|
||||||
|
config.Mode = strings.TrimSpace(strings.ToLower(config.Mode))
|
||||||
|
if config.Mode == "" {
|
||||||
|
config.Mode = "disabled"
|
||||||
|
}
|
||||||
|
if config.SendEditorAutomationID == "" {
|
||||||
|
config.SendEditorAutomationID = "rtbSendMessage"
|
||||||
|
}
|
||||||
|
if config.SendButtonAutomationID == "" {
|
||||||
|
config.SendButtonAutomationID = "btnSend"
|
||||||
|
}
|
||||||
|
if caller == nil {
|
||||||
|
timeout := 10 * time.Second
|
||||||
|
if config.TimeoutSeconds > 0 {
|
||||||
|
timeout = time.Duration(config.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
caller = helperClientUiaCaller{client: helperclient.Client{HelperPath: config.HelperPath, Timeout: timeout}}
|
||||||
|
}
|
||||||
|
return uiaSendMessageConnector{config: config, caller: caller}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c uiaSendMessageConnector) ExecuteSendMessage(ctx context.Context, request SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
||||||
|
if c.config.Mode != "enabled" && c.config.Mode != "uia_rpa" {
|
||||||
|
result := SendMessageConnectorResult{
|
||||||
|
Accepted: false,
|
||||||
|
Status: "blocked",
|
||||||
|
ErrorCode: "uia_rpa_mode_blocked",
|
||||||
|
ErrorMessage: "A-route UIA/RPA send adapter is disabled",
|
||||||
|
ConnectorMode: "uia-rpa-disabled",
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf(result.ErrorMessage)
|
||||||
|
}
|
||||||
|
if err := validateUiaSendMessageConfig(c.config); err != nil {
|
||||||
|
result := SendMessageConnectorResult{
|
||||||
|
Accepted: false,
|
||||||
|
Status: "blocked",
|
||||||
|
ErrorCode: "uia_rpa_config_missing",
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
ConnectorMode: "uia-rpa",
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := validateUiaSendMessageRequest(request); err != nil {
|
||||||
|
return SendMessageConnectorResult{
|
||||||
|
Accepted: false,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorCode: "uia_rpa_invalid_request",
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
ConnectorMode: "uia-rpa",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := map[string]any{
|
||||||
|
"hwnd": c.config.Hwnd,
|
||||||
|
"send_editor_automation_id": c.config.SendEditorAutomationID,
|
||||||
|
"send_button_automation_id": c.config.SendButtonAutomationID,
|
||||||
|
"target_ref": request.TargetRef,
|
||||||
|
"content_text": request.ContentText,
|
||||||
|
"content_sha256": request.ContentSHA256,
|
||||||
|
"idempotency_key_sha256": request.IdempotencyKeySHA256,
|
||||||
|
}
|
||||||
|
response, err := c.caller.CallUiaHelper(ctx, uiaSendMessageOp, args)
|
||||||
|
if err != nil {
|
||||||
|
return SendMessageConnectorResult{
|
||||||
|
Accepted: false,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorCode: "uia_rpa_helper_error",
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
ConnectorMode: "uia-rpa",
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
if !response.OK {
|
||||||
|
code := strings.TrimSpace(response.ErrCode)
|
||||||
|
if code == "" {
|
||||||
|
code = "uia_rpa_helper_rejected"
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(response.ErrText)
|
||||||
|
if message == "" {
|
||||||
|
message = "UIA helper rejected send action"
|
||||||
|
}
|
||||||
|
return SendMessageConnectorResult{
|
||||||
|
Accepted: false,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorCode: code,
|
||||||
|
ErrorMessage: message,
|
||||||
|
ConnectorMode: "uia-rpa",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
ackRef, _ := response.Data["ack_ref"].(string)
|
||||||
|
if strings.TrimSpace(ackRef) == "" {
|
||||||
|
ackRef = "uia:" + c.config.Hwnd + ":" + c.config.SendButtonAutomationID
|
||||||
|
}
|
||||||
|
return SendMessageConnectorResult{
|
||||||
|
Accepted: true,
|
||||||
|
Status: "accepted",
|
||||||
|
AckRef: ackRef,
|
||||||
|
ConnectorMode: "uia-rpa",
|
||||||
|
ProductionEnabled: true,
|
||||||
|
SideEffects: map[string]any{
|
||||||
|
"sent_message": true,
|
||||||
|
"typed_text": true,
|
||||||
|
"clicked_ui": true,
|
||||||
|
"uploaded_file": false,
|
||||||
|
"sent_file": false,
|
||||||
|
"captured_network": false,
|
||||||
|
"attached_hook": false,
|
||||||
|
"modified_client_data": false,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUiaSendMessageConfig(config UiaSendMessageAdapterConfig) error {
|
||||||
|
if strings.TrimSpace(config.Hwnd) == "" {
|
||||||
|
return fmt.Errorf("ISPHERE_SEND_UIA_HWND is required for A-route UIA send")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.SendEditorAutomationID) == "" {
|
||||||
|
return fmt.Errorf("send editor automation id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.SendButtonAutomationID) == "" {
|
||||||
|
return fmt.Errorf("send button automation id is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUiaSendMessageRequest(request SendMessageConnectorRequest) error {
|
||||||
|
if strings.TrimSpace(request.TargetRef) == "" {
|
||||||
|
return fmt.Errorf("target_ref is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(request.ContentText) == "" {
|
||||||
|
return fmt.Errorf("content_text 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c helperClientUiaCaller) CallUiaHelper(ctx context.Context, op string, args map[string]any) (uiaHelperResponse, error) {
|
||||||
|
response, err := c.client.Call(ctx, op, args)
|
||||||
|
if err != nil {
|
||||||
|
return uiaHelperResponse{}, err
|
||||||
|
}
|
||||||
|
out := uiaHelperResponse{OK: response != nil && response.OK}
|
||||||
|
if response != nil {
|
||||||
|
out.Data = decodeRawData(response.Data)
|
||||||
|
if response.Error != nil {
|
||||||
|
out.ErrCode = response.Error.Code
|
||||||
|
out.ErrText = response.Error.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Data == nil {
|
||||||
|
out.Data = map[string]any{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiaHelperResponseFromRaw(data map[string]json.RawMessage, ok bool) uiaHelperResponse {
|
||||||
|
return uiaHelperResponse{OK: ok, Data: decodeRawData(data)}
|
||||||
|
}
|
||||||
117
internal/tools/send_message_uia_adapter_test.go
Normal file
117
internal/tools/send_message_uia_adapter_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUiaSendMessageConnectorRequiresEnabledModeAndWindowConfig(t *testing.T) {
|
||||||
|
connector := NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{Mode: "disabled"}, &fakeUiaHelperCaller{})
|
||||||
|
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||||
|
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||||
|
ContentText: "hello",
|
||||||
|
ContentSHA256: sha256HexForSendMessageTest("hello"),
|
||||||
|
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem"),
|
||||||
|
ExecutionMode: sendMessageProductionMode,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected disabled connector error")
|
||||||
|
}
|
||||||
|
if result.ErrorCode != "uia_rpa_mode_blocked" || result.ConnectorMode != "uia-rpa-disabled" {
|
||||||
|
t.Fatalf("unexpected result: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
connector = NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{Mode: "enabled"}, &fakeUiaHelperCaller{})
|
||||||
|
result, err = connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||||
|
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||||
|
ContentText: "hello",
|
||||||
|
ContentSHA256: sha256HexForSendMessageTest("hello"),
|
||||||
|
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem"),
|
||||||
|
ExecutionMode: sendMessageProductionMode,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected missing config error")
|
||||||
|
}
|
||||||
|
if result.ErrorCode != "uia_rpa_config_missing" {
|
||||||
|
t.Fatalf("unexpected missing config result: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUiaSendMessageConnectorCallsHelperAndMapsAck(t *testing.T) {
|
||||||
|
caller := &fakeUiaHelperCaller{response: uiaHelperResponse{
|
||||||
|
OK: true,
|
||||||
|
Data: map[string]any{
|
||||||
|
"action_mode": "uia_send_message",
|
||||||
|
"target_ref": "contact:alice@imopenfire1-lanzhou",
|
||||||
|
"content_sha256": sha256HexForSendMessageTest("hello uia"),
|
||||||
|
"sent_message": true,
|
||||||
|
"typed_text": true,
|
||||||
|
"clicked_ui": true,
|
||||||
|
"ack_ref": "uia:0x1234:btnSend",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
connector := NewUiaSendMessageConnector(UiaSendMessageAdapterConfig{
|
||||||
|
Mode: "enabled",
|
||||||
|
Hwnd: "0x1234",
|
||||||
|
SendEditorAutomationID: "rtbSendMessage",
|
||||||
|
SendButtonAutomationID: "btnSend",
|
||||||
|
}, caller)
|
||||||
|
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||||
|
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||||
|
ContentText: "hello uia",
|
||||||
|
ContentSHA256: sha256HexForSendMessageTest("hello uia"),
|
||||||
|
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem-uia"),
|
||||||
|
ExecutionMode: sendMessageProductionMode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExecuteSendMessage: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Accepted || result.Status != "accepted" || result.AckRef != "uia:0x1234:btnSend" || result.ConnectorMode != "uia-rpa" || !result.ProductionEnabled {
|
||||||
|
t.Fatalf("unexpected result: %+v", result)
|
||||||
|
}
|
||||||
|
if caller.op != "uia_send_message" {
|
||||||
|
t.Fatalf("helper op = %q", caller.op)
|
||||||
|
}
|
||||||
|
if caller.args["hwnd"] != "0x1234" || caller.args["send_editor_automation_id"] != "rtbSendMessage" || caller.args["send_button_automation_id"] != "btnSend" {
|
||||||
|
t.Fatalf("unexpected helper args: %#v", caller.args)
|
||||||
|
}
|
||||||
|
if caller.args["content_text"] != "hello uia" || caller.args["content_sha256"] != sha256HexForSendMessageTest("hello uia") {
|
||||||
|
t.Fatalf("helper did not receive send content/hash: %#v", caller.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMessageConnectorFromEnvBuildsUiaRPAConnector(t *testing.T) {
|
||||||
|
t.Setenv(EnvSendConnectorMode, "uia_rpa")
|
||||||
|
t.Setenv(EnvSendUIAHwnd, "0x1234")
|
||||||
|
t.Setenv(EnvSendUIAEditorAutomationID, "editorA")
|
||||||
|
t.Setenv(EnvSendUIButtonAutomationID, "buttonA")
|
||||||
|
t.Setenv(EnvSendUIAHelperPath, t.TempDir()+"\\missing-helper.exe")
|
||||||
|
|
||||||
|
connector := NewSendMessageConnectorFromEnv()
|
||||||
|
if connector == nil {
|
||||||
|
t.Fatalf("NewSendMessageConnectorFromEnv returned nil for uia_rpa config")
|
||||||
|
}
|
||||||
|
result, err := connector.ExecuteSendMessage(context.Background(), SendMessageConnectorRequest{
|
||||||
|
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
||||||
|
ContentText: "hello",
|
||||||
|
ContentSHA256: sha256HexForSendMessageTest("hello"),
|
||||||
|
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem"),
|
||||||
|
ExecutionMode: sendMessageProductionMode,
|
||||||
|
})
|
||||||
|
if err == nil || result.ErrorCode != "uia_rpa_helper_error" {
|
||||||
|
t.Fatalf("expected configured connector to reach helper layer and fail on missing helper, got result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeUiaHelperCaller struct {
|
||||||
|
response uiaHelperResponse
|
||||||
|
err error
|
||||||
|
op string
|
||||||
|
args map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUiaHelperCaller) CallUiaHelper(ctx context.Context, op string, args map[string]any) (uiaHelperResponse, error) {
|
||||||
|
f.op = op
|
||||||
|
f.args = args
|
||||||
|
return f.response, f.err
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
|
||||||
@@ -53,6 +53,9 @@ namespace ISphereWinHelper
|
|||||||
case "probe_send_uia_controls":
|
case "probe_send_uia_controls":
|
||||||
response = SendConnectorPreflight.ProbeSendUiaControls(requestId, op, opArgs);
|
response = SendConnectorPreflight.ProbeSendUiaControls(requestId, op, opArgs);
|
||||||
break;
|
break;
|
||||||
|
case "uia_send_message":
|
||||||
|
response = UiaSendAction.SendMessage(requestId, op, opArgs);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op);
|
response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op);
|
||||||
break;
|
break;
|
||||||
@@ -88,7 +91,7 @@ namespace ISphereWinHelper
|
|||||||
return new Dictionary<string, object>
|
return new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "helper_name", "ISphereWinHelper" },
|
{ "helper_name", "ISphereWinHelper" },
|
||||||
{ "helper_version", "0.4.0" },
|
{ "helper_version", "0.5.0" },
|
||||||
{ "protocol", HelperProtocol.Protocol },
|
{ "protocol", HelperProtocol.Protocol },
|
||||||
{ "runtime", ".NET Framework " + Environment.Version }
|
{ "runtime", ".NET Framework " + Environment.Version }
|
||||||
};
|
};
|
||||||
|
|||||||
214
native/ISphereWinHelper/UiaSendAction.cs
Normal file
214
native/ISphereWinHelper/UiaSendAction.cs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Automation;
|
||||||
|
|
||||||
|
namespace ISphereWinHelper
|
||||||
|
{
|
||||||
|
internal static class UiaSendAction
|
||||||
|
{
|
||||||
|
private const int WM_SETTEXT = 0x000C;
|
||||||
|
private const int BM_CLICK = 0x00F5;
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, string lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
public static Dictionary<string, object> SendMessage(string requestId, string op, Dictionary<string, object> 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
string editorAutomationId = HelperProtocol.GetString(args, "send_editor_automation_id", "rtbSendMessage");
|
||||||
|
string buttonAutomationId = HelperProtocol.GetString(args, "send_button_automation_id", "btnSend");
|
||||||
|
string contentText = HelperProtocol.GetString(args, "content_text", "");
|
||||||
|
string contentSha256 = HelperProtocol.GetString(args, "content_sha256", "").Trim().ToLowerInvariant();
|
||||||
|
string targetRef = HelperProtocol.GetString(args, "target_ref", "");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(editorAutomationId))
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CONFIG_MISSING", "send_editor_automation_id is required");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(buttonAutomationId))
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CONFIG_MISSING", "send_button_automation_id is required");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(contentText))
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CONTENT_MISSING", "content_text is required");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(contentSha256) || contentSha256 != Sha256Hex(contentText))
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_HASH_MISMATCH", "content_sha256 does not match content_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AutomationElement root = AutomationElement.FromHandle(hwnd);
|
||||||
|
if (root == null)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "no UI Automation element for hwnd");
|
||||||
|
}
|
||||||
|
|
||||||
|
AutomationElement editor = FindByAutomationId(root, editorAutomationId);
|
||||||
|
if (editor == null)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_EDITOR_NOT_FOUND", "send editor control not found: " + editorAutomationId);
|
||||||
|
}
|
||||||
|
AutomationElement button = FindByAutomationId(root, buttonAutomationId);
|
||||||
|
if (button == null)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_BUTTON_NOT_FOUND", "send button control not found: " + buttonAutomationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool typed = SetElementText(editor, contentText);
|
||||||
|
if (!typed)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_SET_TEXT_FAILED", "could not set send editor text");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool clicked = InvokeElement(button);
|
||||||
|
if (!clicked)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_CLICK_FAILED", "could not invoke send button");
|
||||||
|
}
|
||||||
|
|
||||||
|
string ackRef = "uia:" + WindowScanner.FormatHwnd(hwnd) + ":" + buttonAutomationId + ":" + ShortHash(contentSha256);
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "action_mode", "uia_send_message" },
|
||||||
|
{ "hwnd", WindowScanner.FormatHwnd(hwnd) },
|
||||||
|
{ "target_ref", targetRef ?? "" },
|
||||||
|
{ "content_sha256", contentSha256 },
|
||||||
|
{ "content_length", contentText.Length },
|
||||||
|
{ "editor_automation_id", editorAutomationId },
|
||||||
|
{ "button_automation_id", buttonAutomationId },
|
||||||
|
{ "editor_found", true },
|
||||||
|
{ "button_found", true },
|
||||||
|
{ "typed_text", true },
|
||||||
|
{ "clicked_ui", true },
|
||||||
|
{ "sent_message", true },
|
||||||
|
{ "uploaded_file", false },
|
||||||
|
{ "sent_file", false },
|
||||||
|
{ "captured_network", false },
|
||||||
|
{ "attached_hook", false },
|
||||||
|
{ "modified_client_data", false },
|
||||||
|
{ "ack_ref", ackRef }
|
||||||
|
};
|
||||||
|
return HelperProtocol.Success(requestId, op, data);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_SEND_FAILED", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AutomationElement FindByAutomationId(AutomationElement root, string automationId)
|
||||||
|
{
|
||||||
|
if (root == null || string.IsNullOrWhiteSpace(automationId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return root.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetElementText(AutomationElement element, string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object pattern;
|
||||||
|
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out pattern))
|
||||||
|
{
|
||||||
|
((ValuePattern)pattern).SetValue(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int nativeHandle = element.Current.NativeWindowHandle;
|
||||||
|
if (nativeHandle != 0)
|
||||||
|
{
|
||||||
|
SendMessage(new IntPtr(nativeHandle), WM_SETTEXT, IntPtr.Zero, text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool InvokeElement(AutomationElement element)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object pattern;
|
||||||
|
if (element.TryGetCurrentPattern(InvokePattern.Pattern, out pattern))
|
||||||
|
{
|
||||||
|
((InvokePattern)pattern).Invoke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int nativeHandle = element.Current.NativeWindowHandle;
|
||||||
|
if (nativeHandle != 0)
|
||||||
|
{
|
||||||
|
SendMessage(new IntPtr(nativeHandle), BM_CLICK, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sha256Hex(string value)
|
||||||
|
{
|
||||||
|
using (SHA256 sha = SHA256.Create())
|
||||||
|
{
|
||||||
|
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? ""));
|
||||||
|
StringBuilder sb = new StringBuilder(hash.Length * 2);
|
||||||
|
foreach (byte b in hash)
|
||||||
|
{
|
||||||
|
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShortHash(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.Length <= 12 ? value : value.Substring(0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||||
@@ -100,7 +100,7 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
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", "ISPHERE_SEND_FILE_AUDIT_PATH", "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"} {
|
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_PRODUCTION_ENABLED", "ISPHERE_SEND_CONNECTOR_MODE", "ISPHERE_SEND_UIA_HELPER_PATH", "ISPHERE_SEND_UIA_HWND", "ISPHERE_SEND_UIA_EDITOR_AUTOMATION_ID", "ISPHERE_SEND_UIA_BUTTON_AUTOMATION_ID", "ISPHERE_SEND_UIA_TIMEOUT_SECONDS", "ISPHERE_SEND_FILE_ALLOWED_DIR", "ISPHERE_SEND_FILE_AUDIT_PATH", "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"} {
|
||||||
if err := os.Unsetenv(key); err != nil {
|
if err := os.Unsetenv(key); err != nil {
|
||||||
fail("server/env", err)
|
fail("server/env", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
param(
|
param(
|
||||||
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe",
|
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe",
|
||||||
[switch]$SkipBuild
|
[switch]$SkipBuild
|
||||||
)
|
)
|
||||||
@@ -107,6 +107,7 @@ if ($dumpMissing.ok -or $dumpMissing.error.code -notin @("WINDOW_NOT_FOUND", "UI
|
|||||||
|
|
||||||
$title = "Codex WinHelper Verify " + [guid]::NewGuid().ToString("N").Substring(0, 8)
|
$title = "Codex WinHelper Verify " + [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||||
$formScriptPath = Join-Path $env:TEMP ("codex-winhelper-form-" + [guid]::NewGuid().ToString("N") + ".ps1")
|
$formScriptPath = Join-Path $env:TEMP ("codex-winhelper-form-" + [guid]::NewGuid().ToString("N") + ".ps1")
|
||||||
|
$sendActionMarkerPath = Join-Path $env:TEMP ("codex-winhelper-send-marker-" + [guid]::NewGuid().ToString("N") + ".json")
|
||||||
$formScript = @"
|
$formScript = @"
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
`$form = New-Object System.Windows.Forms.Form
|
`$form = New-Object System.Windows.Forms.Form
|
||||||
@@ -137,6 +138,10 @@ Add-Type -AssemblyName System.Windows.Forms
|
|||||||
`$button.Name = 'btnSend'
|
`$button.Name = 'btnSend'
|
||||||
`$button.Text = 'Send'
|
`$button.Text = 'Send'
|
||||||
`$button.Dock = 'Fill'
|
`$button.Dock = 'Fill'
|
||||||
|
`$button.Add_Click({
|
||||||
|
`$payload = [ordered]@{ clicked = `$true; text = `$send.Text } | ConvertTo-Json -Compress
|
||||||
|
Set-Content -LiteralPath '$sendActionMarkerPath' -Value `$payload -Encoding UTF8
|
||||||
|
})
|
||||||
`$layout.Controls.Add(`$button)
|
`$layout.Controls.Add(`$button)
|
||||||
`$file = New-Object System.Windows.Forms.Button
|
`$file = New-Object System.Windows.Forms.Button
|
||||||
`$file.Name = 'btnFile'
|
`$file.Name = 'btnFile'
|
||||||
@@ -196,6 +201,42 @@ try {
|
|||||||
if ($sendUiaProbe.data.safety.sent_message -or $sendUiaProbe.data.safety.uploaded_file -or $sendUiaProbe.data.safety.clicked_ui -or $sendUiaProbe.data.safety.typed_text) {
|
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)"
|
throw "probe_send_uia_controls safety flags failed: $($sendUiaProbe | ConvertTo-Json -Depth 12 -Compress)"
|
||||||
}
|
}
|
||||||
|
$sendContent = "Codex synthetic UIA send " + [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||||
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($sendContent)
|
||||||
|
$sendContentHash = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha.Dispose()
|
||||||
|
}
|
||||||
|
$sendAction = Invoke-HelperJson @{
|
||||||
|
protocol = "isphere.helper.v1"
|
||||||
|
request_id = "verify-uia-send-message"
|
||||||
|
op = "uia_send_message"
|
||||||
|
timeout_ms = 5000
|
||||||
|
args = @{
|
||||||
|
hwnd = $candidate.hwnd
|
||||||
|
send_editor_automation_id = "rtbSendMessage"
|
||||||
|
send_button_automation_id = "btnSend"
|
||||||
|
target_ref = "synthetic:verify"
|
||||||
|
content_text = $sendContent
|
||||||
|
content_sha256 = $sendContentHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $sendAction.ok -or $sendAction.data.action_mode -ne "uia_send_message" -or -not $sendAction.data.sent_message -or -not $sendAction.data.typed_text -or -not $sendAction.data.clicked_ui) {
|
||||||
|
throw "uia_send_message failed: $($sendAction | ConvertTo-Json -Depth 12 -Compress)"
|
||||||
|
}
|
||||||
|
for ($i = 0; $i -lt 20 -and -not (Test-Path -LiteralPath $sendActionMarkerPath); $i++) {
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $sendActionMarkerPath)) {
|
||||||
|
throw "uia_send_message did not trigger synthetic send button marker"
|
||||||
|
}
|
||||||
|
$marker = Get-Content -LiteralPath $sendActionMarkerPath -Raw | ConvertFrom-Json
|
||||||
|
if (-not $marker.clicked -or $marker.text -ne $sendContent) {
|
||||||
|
throw "uia_send_message marker mismatch: $($marker | ConvertTo-Json -Compress)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if ($process -and -not $process.HasExited) {
|
if ($process -and -not $process.HasExited) {
|
||||||
@@ -204,6 +245,9 @@ finally {
|
|||||||
if (Test-Path -LiteralPath $formScriptPath) {
|
if (Test-Path -LiteralPath $formScriptPath) {
|
||||||
Remove-Item -LiteralPath $formScriptPath -Force
|
Remove-Item -LiteralPath $formScriptPath -Force
|
||||||
}
|
}
|
||||||
|
if (Test-Path -LiteralPath $sendActionMarkerPath) {
|
||||||
|
Remove-Item -LiteralPath $sendActionMarkerPath -Force
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[ordered]@{
|
[ordered]@{
|
||||||
@@ -214,5 +258,6 @@ finally {
|
|||||||
runtime_probe_target_count = $runtimeProbe.data.target_count
|
runtime_probe_target_count = $runtimeProbe.data.target_count
|
||||||
send_entrypoint_probe_mode = $entrypointProbe.data.probe_mode
|
send_entrypoint_probe_mode = $entrypointProbe.data.probe_mode
|
||||||
synthetic_send_uia_route_hint = $sendUiaProbe.data.flags.route_hint
|
synthetic_send_uia_route_hint = $sendUiaProbe.data.flags.route_hint
|
||||||
|
synthetic_uia_send_action = $sendAction.data.action_mode
|
||||||
uia_available = $selfCheck.data.uia_available
|
uia_available = $selfCheck.data.uia_available
|
||||||
} | ConvertTo-Json -Depth 4 -Compress
|
} | ConvertTo-Json -Depth 4 -Compress
|
||||||
|
|||||||
Reference in New Issue
Block a user