diff --git a/docs/current-status-card.md b/docs/current-status-card.md index 892b8d9..fbd1609 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -383,3 +383,16 @@ R14 release-candidate report is complete: - Updated `docs\go-mcp-runbook.md` with the R14 business status and package paths. - Verification passed: package builders/verifiers, `git diff --check`, `scripts\verify-business-goals-smoke.ps1`, `go test ./...`, `go build ./cmd/isphere-mcp`, and `scripts\verify-go-mcp.ps1`. - Final R14 business conclusion: search contacts, search groups, receive messages, and receive-file list are usable; send-message preview and send-file preview are usable; production send, real file download, and production file upload remain evidence-blocked. + + +## A-route UIA send branch + +A-route UIA send branch is in progress on `codex/a-route-rpa-send`: + +- User direction: do not add a product approval gate; implement function first and let the digital employee layer decide when to call it. +- Added plan `docs\superpowers\plans\2026-07-11-a-route-rpa-send.md`. +- Go connector request now carries raw `ContentText` only to the action connector; response and audit remain redacted. +- Added Go `uia-rpa` send connector adapter and env loader. +- Added C# helper op `uia_send_message` that sets the send editor text and invokes the send button through UI Automation. +- Synthetic local verification in `scripts\verify-win-helper.ps1` proves the helper can write to a WinForms send box and trigger a send button marker. +- Default MCP smoke still clears A-route env and remains preview/blocked unless explicitly configured. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 98c33bb..b29013a 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -574,7 +574,38 @@ runs\send-file-sandbox-gate-package.zip 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 `win_helper_version` fails, rerun `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1` first. diff --git a/docs/source-discovery/2026-07-11-a-route-uia-send.md b/docs/source-discovery/2026-07-11-a-route-uia-send.md new file mode 100644 index 0000000..0f9a05c --- /dev/null +++ b/docs/source-discovery/2026-07-11-a-route-uia-send.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-11-a-route-rpa-send.md b/docs/superpowers/plans/2026-07-11-a-route-rpa-send.md new file mode 100644 index 0000000..05d4825 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-a-route-rpa-send.md @@ -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`. diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index d5a0193..6396586 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -61,7 +61,7 @@ func NewServerFromEnv() (*mcp.Server, error) { if err != nil { return nil, err } - return NewServerWithSourcesAndMsgLibReceive(source, displaySource, msglibReceiveSource), nil + return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, tools.NewSendMessageConnectorFromEnv()), nil } 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 { + return NewServerWithSourcesMsgLibReceiveAndSendConnector(source, displaySource, msglibReceiveSource, nil) +} + +func NewServerWithSourcesMsgLibReceiveAndSendConnector(source tools.ReceiveMessagesSource, displaySource tools.DisplayEntitySource, msglibReceiveSource tools.ReceiveMessagesSource, sendConnector tools.SendMessageConnector) *mcp.Server { server := mcp.NewServer(&mcp.Implementation{ Name: ServerName, Title: ServerTitle, @@ -111,7 +115,7 @@ func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, di tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource) tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource) tools.RegisterISphereFileTools(server, source) - tools.RegisterISphereSendMessageTool(server, nil) + tools.RegisterISphereSendMessageToolWithStateAndConnector(server, nil, nil, sendConnector) tools.RegisterISphereSendFileTool(server) return server } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 88271bc..e987308 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -4,7 +4,9 @@ import ( "context" "crypto/cipher" "crypto/des" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "os" "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 { displayCalls []msglib.DisplayEntitiesOptions listCalls []msglib.ListMessagesOptions @@ -376,3 +428,8 @@ func padPacketLogLineForServerTest(data []byte, blockSize int) []byte { } return out } + +func sha256HexForServerTest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/tools/isphere_send_message.go b/internal/tools/isphere_send_message.go index a275400..b76400f 100644 --- a/internal/tools/isphere_send_message.go +++ b/internal/tools/isphere_send_message.go @@ -273,6 +273,7 @@ type normalizedSendMessageArgs struct { TargetType string TargetID string TargetRef string + ContentText string ContentLength int ContentSHA256 string IdempotencyKeySHA256 string @@ -308,6 +309,7 @@ func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs, TargetType: targetType, TargetID: targetID, TargetRef: targetRefPrefix + ":" + targetID, + ContentText: contentText, ContentLength: len(contentText), ContentSHA256: contentHash, IdempotencyKeySHA256: sha256Hex(idempotencyKey), @@ -448,6 +450,12 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage response["send_status"] = normalized.Status response["connector_mode"] = normalized.ConnectorMode response["connector_stage"] = normalized.Status + if normalized.ProductionEnabled { + response["production_send_enabled"] = true + } + if len(normalized.SideEffects) > 0 { + response["side_effects"] = normalized.SideEffects + } if normalized.Accepted { response["blocked_reason"] = nil } else if normalized.ErrorMessage != "" { @@ -471,6 +479,9 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage if normalized.AckRef != "" { audit["ack_ref"] = normalized.AckRef } + if normalized.ProductionEnabled { + audit["production_send_enabled"] = true + } if normalized.ErrorCode != "" { audit["error_code"] = normalized.ErrorCode } @@ -487,6 +498,9 @@ func applySendMessageConnectorResult(response map[string]any, event *SendMessage event.AckRef = normalized.AckRef event.ErrorCode = normalized.ErrorCode event.ErrorMessage = normalized.ErrorMessage + if len(normalized.SideEffects) > 0 { + event.SideEffects = normalized.SideEffects + } } } diff --git a/internal/tools/isphere_send_message_test.go b/internal/tools/isphere_send_message_test.go index be3cc71..1e278b1 100644 --- a/internal/tools/isphere_send_message_test.go +++ b/internal/tools/isphere_send_message_test.go @@ -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) { session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore()) @@ -650,13 +696,15 @@ func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ contex } type fakeSendMessageConnector struct { - result SendMessageConnectorResult - err error - calls int + result SendMessageConnectorResult + err error + 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.lastRequest = request return f.result, f.err } @@ -664,3 +712,11 @@ func sha256HexForSendMessageTest(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } + +func mustMarshalStringForSendMessageTest(value any) string { + payload, err := json.Marshal(value) + if err != nil { + panic(err) + } + return string(payload) +} diff --git a/internal/tools/send_message_connector.go b/internal/tools/send_message_connector.go index 153dc9b..f534001 100644 --- a/internal/tools/send_message_connector.go +++ b/internal/tools/send_message_connector.go @@ -13,6 +13,7 @@ type SendMessageConnectorRequest struct { TargetType string TargetID string TargetRef string + ContentText string ContentSHA256 string ContentLength int IdempotencyKeySHA256 string @@ -20,12 +21,14 @@ type SendMessageConnectorRequest struct { } type SendMessageConnectorResult struct { - Accepted bool - Status string - AckRef string - ErrorCode string - ErrorMessage string - ConnectorMode string + Accepted bool + Status string + AckRef string + ErrorCode string + ErrorMessage string + ConnectorMode string + ProductionEnabled bool + SideEffects map[string]any } func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest { @@ -33,6 +36,7 @@ func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) TargetType: input.TargetType, TargetID: input.TargetID, TargetRef: input.TargetRef, + ContentText: input.ContentText, ContentSHA256: input.ContentSHA256, ContentLength: input.ContentLength, IdempotencyKeySHA256: input.IdempotencyKeySHA256, diff --git a/internal/tools/send_message_uia_adapter.go b/internal/tools/send_message_uia_adapter.go new file mode 100644 index 0000000..b98b245 --- /dev/null +++ b/internal/tools/send_message_uia_adapter.go @@ -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)} +} diff --git a/internal/tools/send_message_uia_adapter_test.go b/internal/tools/send_message_uia_adapter_test.go new file mode 100644 index 0000000..97c630c --- /dev/null +++ b/internal/tools/send_message_uia_adapter_test.go @@ -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 +} diff --git a/native/ISphereWinHelper/Program.cs b/native/ISphereWinHelper/Program.cs index 7d1c7fe..366c643 100644 --- a/native/ISphereWinHelper/Program.cs +++ b/native/ISphereWinHelper/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; @@ -53,6 +53,9 @@ namespace ISphereWinHelper case "probe_send_uia_controls": response = SendConnectorPreflight.ProbeSendUiaControls(requestId, op, opArgs); break; + case "uia_send_message": + response = UiaSendAction.SendMessage(requestId, op, opArgs); + break; default: response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op); break; @@ -88,7 +91,7 @@ namespace ISphereWinHelper return new Dictionary { { "helper_name", "ISphereWinHelper" }, - { "helper_version", "0.4.0" }, + { "helper_version", "0.5.0" }, { "protocol", HelperProtocol.Protocol }, { "runtime", ".NET Framework " + Environment.Version } }; @@ -113,4 +116,4 @@ namespace ISphereWinHelper }; } } -} +} diff --git a/native/ISphereWinHelper/UiaSendAction.cs b/native/ISphereWinHelper/UiaSendAction.cs new file mode 100644 index 0000000..707c5f8 --- /dev/null +++ b/native/ISphereWinHelper/UiaSendAction.cs @@ -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 SendMessage(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"); + } + + 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 + { + { "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); + } + } +} diff --git a/scripts/verify-go-mcp.ps1 b/scripts/verify-go-mcp.ps1 index e4e43f7..00c402e 100644 --- a/scripts/verify-go-mcp.ps1 +++ b/scripts/verify-go-mcp.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" Set-StrictMode -Version Latest $repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path @@ -100,7 +100,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 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 { fail("server/env", err) } diff --git a/scripts/verify-win-helper.ps1 b/scripts/verify-win-helper.ps1 index 06cd747..774b78c 100644 --- a/scripts/verify-win-helper.ps1 +++ b/scripts/verify-win-helper.ps1 @@ -1,4 +1,4 @@ -param( +param( [string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe", [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) $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 = @" Add-Type -AssemblyName System.Windows.Forms `$form = New-Object System.Windows.Forms.Form @@ -137,6 +138,10 @@ Add-Type -AssemblyName System.Windows.Forms `$button.Name = 'btnSend' `$button.Text = 'Send' `$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) `$file = New-Object System.Windows.Forms.Button `$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) { 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 { if ($process -and -not $process.HasExited) { @@ -204,6 +245,9 @@ finally { if (Test-Path -LiteralPath $formScriptPath) { Remove-Item -LiteralPath $formScriptPath -Force } + if (Test-Path -LiteralPath $sendActionMarkerPath) { + Remove-Item -LiteralPath $sendActionMarkerPath -Force + } } [ordered]@{ @@ -214,5 +258,6 @@ finally { 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 + synthetic_uia_send_action = $sendAction.data.action_mode uia_available = $selfCheck.data.uia_available } | ConvertTo-Json -Depth 4 -Compress