N12R/N13 internal sandbox handoff and UIA selector catalog #1

Merged
szz merged 11 commits from codex/n13-uia-selector-catalog into main 2026-07-09 07:27:13 +00:00
14 changed files with 2533 additions and 0 deletions

6
.gitignore vendored
View File

@@ -18,3 +18,9 @@ obj/
dist/
__pycache__/
.venv/
# Local handoff prompt
/给接手人的交接话术.md
# Local intake scratch directory
/temp/

View File

@@ -0,0 +1,341 @@
# N12R 内网操作员最小重做步骤
日期2026-07-09
适用对象:内网测试沙盒操作员。
目的:修正本次 N12R 返回包中 `selected-window.json` 缺失和 `dump_uia` 失败的问题,重新生成一个可被外网校验接受的 N12R 采集包。
## 1. 本次失败原因
本次返回包被外网校验拒收,核心原因是窗口句柄写错了。
日志中写成了:
```powershell
$selectedHwnd = "<0x290E6E>"
```
这是错误写法。`<HWND>` 里的尖括号只是提示格式,实际填写时必须删掉。
正确写法是:
```powershell
$selectedHwnd = "0x290E6E"
```
因为多了尖括号,后续窗口匹配失败:
```text
selected hwnd not found in scan result: <0x290E6E>
```
所以:
1. `windows/selected-window.json` 没有生成。
2. `dump_uia` 使用了无效 hwnd。
3. `uia/dump-uia-main.json` 返回 `WINDOW_NOT_FOUND`
## 2. 重做前确认
在内网测试沙盒中打开 PowerShell进入仓库根目录
```powershell
cd E:\isphere-ai-bridge
```
确认 iSphere / IMPlatformClient 已经人工登录,并且主窗口可见、没有最小化。
不要执行以下动作:
```text
搜索联系人
打开会话
发送消息
发送文件
接收/下载文件
自动登录
注入
hook
读取内存
绕过安防
```
## 3. 推荐做法:重新运行 scan_windows
窗口句柄可能已经变化,所以推荐先重新扫描窗口。
如果沿用原采集编号:
```powershell
$captureId = "2026-07-08-internal-sandbox-a"
$captureRoot = "runs\internal-sandbox-live-capture\$captureId"
$helper = Resolve-Path -LiteralPath "runs\win-helper\ISphereWinHelper.exe"
```
重新扫描可见窗口:
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-scan-windows-retry"
op = "scan_windows"
timeout_ms = 5000
args = @{ include_all_visible = $true }
} | ConvertTo-Json -Depth 8 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8
```
查看候选窗口:
```powershell
Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json |
Select-Object -ExpandProperty data |
Select-Object -ExpandProperty windows |
Where-Object { $_.process_name -match "iSphere|IMPlatform|IMPP" -or $_.title -match "iSphere|IMPlatform|IMPP" } |
Format-Table hwnd,pid,process_name,title,class_name,visible,score -AutoSize
```
从输出中选择 iSphere / IMPlatformClient 主窗口的 `hwnd`
示例中之前的候选值是:
```text
0x290E6E
```
如果这次扫描输出变了,请使用新的 `hwnd`
## 4. 正确保存 selected-window.json
把下面命令中的 `0x290E6E` 改成你刚刚确认的真实窗口句柄。
注意:不要加尖括号。
正确:
```powershell
$selectedHwnd = "0x290E6E"
```
错误:
```powershell
$selectedHwnd = "<0x290E6E>"
```
保存所选窗口元数据:
```powershell
$selectedHwnd = "0x290E6E"
$scanObj = Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json
$selected = $scanObj.data.windows | Where-Object { $_.hwnd -eq $selectedHwnd } | Select-Object -First 1
if (-not $selected) { throw "selected hwnd not found in scan result: $selectedHwnd" }
$selected | ConvertTo-Json -Depth 8 | Set-Content -Path "$captureRoot\windows\selected-window.json" -Encoding UTF8
```
确认文件已经生成:
```powershell
Test-Path -LiteralPath "$captureRoot\windows\selected-window.json"
```
期望输出:
```text
True
```
## 5. 重新执行 dump_uia
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-dump-uia-main-retry"
op = "dump_uia"
timeout_ms = 15000
args = @{
hwnd = $selectedHwnd
max_depth = 8
include_text = $true
max_children = 100
}
} | ConvertTo-Json -Depth 12 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8
```
检查结果是否成功:
```powershell
$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json
$dump.ok
$dump.error
```
期望:
```text
True
```
并且 `$dump.error` 应为空或为 `null`
如果仍然是 `WINDOW_NOT_FOUND`,说明窗口句柄已经失效。请回到第 3 步重新扫描并选择新的 hwnd。
## 6. 重新创建脱敏副本
```powershell
Copy-Item -LiteralPath "$captureRoot\uia\dump-uia-main.json" -Destination "$captureRoot\uia\dump-uia-main-redacted.json" -Force
```
然后人工打开:
```text
$captureRoot\uia\dump-uia-main-redacted.json
```
脱敏可见敏感文本,例如:
```text
人员姓名
手机号
邮箱
消息文本
联系人名称
敏感业务标题
类似 token / 密码 / cookie 的字符串
```
尽量保留这些结构字段:
```text
control_type
automation_id
class_name
framework_id
bounds
is_enabled
is_offscreen
children
```
## 7. 修正 source-notes.txt
打开:
```text
$captureRoot\metadata\source-notes.txt
```
把这些字段从 `是/否` 改成真实值:
```text
iSphere manually logged in: 是
client opened before capture: 是
main window visible: 是
```
如果真实情况不是“是”,不要继续打包,先重新完成对应动作。
## 8. 修正 operator-notes.txt
打开:
```text
$captureRoot\notes\operator-notes.txt
```
不要保留 `<...>` 示例内容。请改成真实说明。
示例:
```text
打开的客户端: iSphere / IMPlatformClient
登录方式: 人工正常公司流程
是否使用 MFA/SSO: 是,不包含任何秘密
所选窗口 hwnd: 0x290E6E
选择该窗口的原因: process_name 为 IMPlatformClient标题为国网甘肃省电力公司visible=Truescore=65
已做脱敏: 已检查 dump-uia-main-redacted.json移除可见敏感文本
采集问题: 首次把 hwnd 写成 <0x290E6E> 导致失败,本次已修正为 0x290E6E 后重新采集
forbidden actions performed: no
```
## 9. 复制出去前必须验证
运行必需文件检查:
```powershell
$required = @(
"$captureRoot\metadata\source-notes.txt",
"$captureRoot\helper\version.json",
"$captureRoot\helper\self-check.json",
"$captureRoot\windows\scan-windows.json",
"$captureRoot\windows\selected-window.json",
"$captureRoot\uia\dump-uia-main.json",
"$captureRoot\uia\dump-uia-main-redacted.json",
"$captureRoot\notes\operator-notes.txt"
)
foreach ($path in $required) {
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $path" }
}
"N12R retry package files present: $captureRoot" | Tee-Object -FilePath "$captureRoot\metadata\verification-summary.txt"
```
运行 JSON 解析检查:
```powershell
Get-Content "$captureRoot\helper\version.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\helper\self-check.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\windows\selected-window.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\uia\dump-uia-main-redacted.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
```
检查 UIA dump 必须是成功:
```powershell
$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json
if (-not $dump.ok) { throw "dump_uia failed: $($dump.error | ConvertTo-Json -Depth 8 -Compress)" }
```
## 10. 重新压缩返回
```powershell
Compress-Archive -LiteralPath $captureRoot -DestinationPath "$captureRoot.zip" -Force
```
把新的 zip 或目录返回给外网协调环境。
返回时请同时说明:
```text
N12R 重采/补采包已准备好。
路径: runs/internal-sandbox-live-capture/2026-07-08-internal-sandbox-a/
人工 iSphere 登录: 是
采集期间主窗口可见: 是
forbidden actions performed: no
本次已修正 hwnd不再包含尖括号selected-window.json 已生成dump_uia ok=true
```
## 11. 外网收到后如何判断
外网协调环境会重新运行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
-PackageRoot <返回包目录> `
-ReportPath <包外报告路径>
```
只有 validator 输出:
```text
Decision: ACCEPT
```
才能进入 N13 selector 设计。

View File

@@ -0,0 +1,943 @@
# N13 UIA Selector Catalog Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an offline, read-only N13 UIA selector catalog and matcher that can validate selectors against the accepted N12R UIA dump without adding live helper or MCP actions.
**Architecture:** Add a focused Go package `internal/uiaselector` with selector data types, a static first-pass catalog, UIA dump loading, and pure in-memory matching. Tests use a redacted N12R fixture and assert that matching returns structural node summaries without visible text.
**Tech Stack:** Go 1.23.x, standard library `encoding/json`, existing repository test style, PowerShell verification wrapper.
---
## Scope and boundary
This plan implements only offline selector catalog and matching.
It does not modify these files:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
```
It does not add MCP tools or helper ops. It does not click, type, open conversations, send messages, send files, receive files, or read chat content.
The accepted N12R evidence used by tests is:
```text
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main-redacted.json
```
## File structure
Create these files:
```text
internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/dump.go
internal/uiaselector/dump_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1
```
Responsibilities:
- `catalog.go`: selector data structures and initial N13 selector catalog.
- `catalog_test.go`: catalog completeness and boundary tests.
- `dump.go`: UIA dump response and node loading helpers.
- `dump_test.go`: fixture parse tests.
- `matcher.go`: pure in-memory selector matcher.
- `matcher_test.go`: matching behavior and no-visible-text result tests.
- `testdata/n12r-2026-07-09-uia-redacted.json`: redacted accepted N12R UIA fixture copied from the ignored run artifact.
- `scripts/verify-n13-uia-selectors.ps1`: convenience verification script for Go-enabled environments.
## Task 1: Add N12R redacted UIA fixture
**Files:**
- Create: `internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json`
- [ ] **Step 1: Create package directory and copy the accepted redacted fixture**
Run:
```powershell
New-Item -ItemType Directory -Force -Path internal\uiaselector\testdata | Out-Null
Copy-Item `
-LiteralPath runs\internal-sandbox-live-capture-intake\2026-07-09-retry-2\2026-07-08-internal-sandbox-a\uia\dump-uia-main-redacted.json `
-Destination internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json `
-Force
```
Expected: `internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json` exists.
- [ ] **Step 2: Verify fixture parses as JSON**
Run:
```powershell
Get-Content internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json -Encoding UTF8 | ConvertFrom-Json | Out-Null
```
Expected: command exits 0.
- [ ] **Step 3: Commit fixture**
```powershell
git add internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json
git commit -m "test: add N13 redacted UIA fixture"
```
## Task 2: Define selector catalog types and initial catalog
**Files:**
- Create: `internal/uiaselector/catalog_test.go`
- Create: `internal/uiaselector/catalog.go`
- [ ] **Step 1: Write failing catalog tests**
Create `internal/uiaselector/catalog_test.go`:
```go
package uiaselector
import (
"strings"
"testing"
)
func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
catalog := DefaultCatalog()
wantIDs := []string{
"main_window",
"main_plugin_tab",
"home_page_window",
"tab_page_content",
"left_panel",
"roster_panel",
"roster_view",
"fuzzy_query_panel",
"fuzzy_query_window",
"fuzzy_search_edit",
}
byID := map[string]Selector{}
for _, selector := range catalog {
byID[selector.ID] = selector
}
for _, id := range wantIDs {
selector, ok := byID[id]
if !ok {
t.Fatalf("DefaultCatalog missing selector %q", id)
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
}
if selector.Required.AutomationID == "" {
t.Fatalf("selector %q must require automation_id", id)
}
if selector.Required.ControlType == "" {
t.Fatalf("selector %q must require control_type", id)
}
if selector.StabilityScore < 80 {
t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
}
}
}
func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
for _, selector := range DefaultCatalog() {
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
}
lower := strings.ToLower(selector.ID + " " + selector.Description)
for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
}
}
}
}
```
- [ ] **Step 2: Run catalog tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
```
Expected: FAIL because package `internal/uiaselector` or `DefaultCatalog` does not exist.
If this environment reports `go` not found, stop execution and record the environment gap. Continue only in a Go 1.23.x environment.
- [ ] **Step 3: Implement catalog types and selectors**
Create `internal/uiaselector/catalog.go`:
```go
package uiaselector
const AllowedUseReadOnlyLocate = "read_only_locate"
type Selector struct {
ID string `json:"id"`
Description string `json:"description"`
AllowedUse string `json:"allowed_use"`
Required Criteria `json:"required"`
Preferred PreferredCriteria `json:"preferred"`
Fallback []FallbackCriteria `json:"fallback"`
StabilityScore int `json:"stability_score"`
Evidence Evidence `json:"evidence"`
}
type Criteria struct {
AutomationID string `json:"automation_id,omitempty"`
ControlType string `json:"control_type,omitempty"`
FrameworkID string `json:"framework_id,omitempty"`
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type PreferredCriteria struct {
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
PathHint string `json:"path_hint,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type FallbackCriteria struct {
AncestorID string `json:"ancestor_selector,omitempty"`
ControlType string `json:"control_type,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"`
ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"`
}
type Evidence struct {
CaptureID string `json:"capture_id"`
UIAPath string `json:"uia_path"`
}
func DefaultCatalog() []Selector {
return []Selector{
{
ID: "main_window",
Description: "iSphere / IMPlatformClient 主窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
StabilityScore: 95,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
},
{
ID: "main_plugin_tab",
Description: "主功能 tab 容器",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
},
{
ID: "home_page_window",
Description: "首页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
},
{
ID: "tab_page_content",
Description: "tab 页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
},
{
ID: "left_panel",
Description: "左侧主面板",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
StabilityScore: 92,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
},
{
ID: "roster_panel",
Description: "组织/联系人容器外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
},
{
ID: "roster_view",
Description: "组织/联系人列表区域",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 86,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
},
{
ID: "fuzzy_query_panel",
Description: "模糊搜索组件外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
},
{
ID: "fuzzy_query_window",
Description: "模糊搜索内部窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
},
{
ID: "fuzzy_search_edit",
Description: "模糊搜索输入框",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
StabilityScore: 93,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
},
}
}
```
- [ ] **Step 4: Run catalog tests and verify they pass**
Run:
```powershell
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit catalog**
```powershell
git add internal\uiaselector\catalog.go internal\uiaselector\catalog_test.go
git commit -m "feat: add N13 UIA selector catalog"
```
## Task 3: Load redacted UIA dump fixture
**Files:**
- Create: `internal/uiaselector/dump_test.go`
- Create: `internal/uiaselector/dump.go`
- [ ] **Step 1: Write failing dump loading tests**
Create `internal/uiaselector/dump_test.go`:
```go
package uiaselector
import "testing"
func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
if root.AutomationID != "frmMain" {
t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
}
if root.ControlType != "Window" {
t.Fatalf("root control_type = %q, want Window", root.ControlType)
}
if got := CountNodes(root); got != 66 {
t.Fatalf("CountNodes = %d, want 66", got)
}
}
```
- [ ] **Step 2: Run dump tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
```
Expected: FAIL because `LoadDumpFile` and `CountNodes` do not exist.
- [ ] **Step 3: Implement dump loading**
Create `internal/uiaselector/dump.go`:
```go
package uiaselector
import (
"encoding/json"
"fmt"
"os"
)
type DumpResponse struct {
OK bool `json:"ok"`
Data *DumpData `json:"data"`
Error any `json:"error"`
}
type DumpData struct {
Root *Node `json:"root"`
}
type Node struct {
Name string `json:"name"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
IsEnabled *bool `json:"is_enabled"`
IsOffscreen *bool `json:"is_offscreen"`
Bounds any `json:"bounds"`
Children []Node `json:"children"`
}
func LoadDumpFile(path string) (*Node, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var response DumpResponse
if err := json.Unmarshal(content, &response); err != nil {
return nil, err
}
if !response.OK {
return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
}
if response.Data == nil || response.Data.Root == nil {
return nil, fmt.Errorf("dump response has no data.root")
}
return response.Data.Root, nil
}
func CountNodes(root *Node) int {
if root == nil {
return 0
}
count := 1
for i := range root.Children {
count += CountNodes(&root.Children[i])
}
return count
}
```
- [ ] **Step 4: Run dump tests and verify they pass**
Run:
```powershell
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit dump loader**
```powershell
git add internal\uiaselector\dump.go internal\uiaselector\dump_test.go
git commit -m "feat: load N13 UIA dump fixture"
```
## Task 4: Implement read-only selector matcher
**Files:**
- Create: `internal/uiaselector/matcher_test.go`
- Create: `internal/uiaselector/matcher.go`
- [ ] **Step 1: Write failing matcher tests**
Create `internal/uiaselector/matcher_test.go`:
```go
package uiaselector
import "testing"
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
tests := []struct {
selectorID string
path string
control string
autoID string
}{
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
}
for _, tt := range tests {
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
if !result.OK {
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
}
if !result.Matched {
t.Fatalf("%s matched=false", tt.selectorID)
}
if result.MatchCount != 1 {
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
}
match := result.Matches[0]
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
t.Fatalf("%s match = %#v", tt.selectorID, match)
}
}
}
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "main_window")
if !result.OK || !result.Matched || len(result.Matches) != 1 {
t.Fatalf("unexpected result: %#v", result)
}
match := result.Matches[0]
if match.Name != "" {
t.Fatalf("match returned visible name text %q", match.Name)
}
if !match.HasName {
t.Fatalf("main_window should report has_name=true")
}
if match.NameLength == 0 {
t.Fatalf("main_window should report non-zero name_length")
}
}
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
if result.OK {
t.Fatalf("OK=true for missing selector: %#v", result)
}
if result.Error.Code != "UNKNOWN_SELECTOR" {
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
}
}
```
- [ ] **Step 2: Run matcher tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run TestMatchSelector -count=1
```
Expected: FAIL because `MatchSelector` and result types do not exist.
- [ ] **Step 3: Implement matcher**
Create `internal/uiaselector/matcher.go`:
```go
package uiaselector
import "strings"
type MatchResult struct {
OK bool `json:"ok"`
SelectorID string `json:"selector_id"`
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
Matches []NodeMatch `json:"matches"`
Warnings []string `json:"warnings"`
Error MatchError `json:"error,omitempty"`
}
type MatchError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type NodeMatch struct {
Path string `json:"path"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
HasName bool `json:"has_name"`
NameLength int `json:"name_length"`
Name string `json:"-"`
}
type nodeRef struct {
path string
node *Node
}
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
selector, ok := findSelector(catalog, selectorID)
if !ok {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
}
refs := flatten(root)
matches := matchCriteria(refs, selector.Required)
if len(matches) == 0 {
for _, fallback := range selector.Fallback {
matches = matchFallback(refs, fallback)
if len(matches) > 0 {
break
}
}
}
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
for _, ref := range matches {
result.Matches = append(result.Matches, summarizeNode(ref))
}
if !result.Matched {
result.Warnings = append(result.Warnings, "selector_not_found")
}
return result
}
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
for _, selector := range catalog {
if selector.ID == selectorID {
return selector, true
}
}
return Selector{}, false
}
func flatten(root *Node) []nodeRef {
refs := []nodeRef{}
var walk func(node *Node, path string)
walk = func(node *Node, path string) {
if node == nil {
return
}
refs = append(refs, nodeRef{path: path, node: node})
for i := range node.Children {
walk(&node.Children[i], path+"/"+itoa(i))
}
}
walk(root, "root")
return refs
}
func itoa(value int) string {
if value == 0 {
return "0"
}
digits := []byte{}
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value = value / 10
}
return string(digits)
}
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
continue
}
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
continue
}
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
continue
}
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
continue
}
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
continue
}
matches = append(matches, ref)
}
return matches
}
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
continue
}
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
continue
}
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
continue
}
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
continue
}
matches = append(matches, ref)
}
return matches
}
func hasChildAutomationIDs(node *Node, ids []string) bool {
for _, id := range ids {
found := false
for i := range node.Children {
if node.Children[i].AutomationID == id {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func hasChildControlTypes(node *Node, controlTypes []string) bool {
for _, controlType := range controlTypes {
found := false
for i := range node.Children {
if node.Children[i].ControlType == controlType {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func summarizeNode(ref nodeRef) NodeMatch {
return NodeMatch{
Path: ref.path,
ControlType: ref.node.ControlType,
AutomationID: ref.node.AutomationID,
ClassName: ref.node.ClassName,
FrameworkID: ref.node.FrameworkID,
HasName: ref.node.Name != "",
NameLength: len(ref.node.Name),
}
}
```
- [ ] **Step 4: Run matcher tests and verify they pass**
Run:
```powershell
go test ./internal/uiaselector -run TestMatchSelector -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit matcher**
```powershell
git add internal\uiaselector\matcher.go internal\uiaselector\matcher_test.go
git commit -m "feat: match N13 UIA selectors offline"
```
## Task 5: Add verification script
**Files:**
- Create: `scripts/verify-n13-uia-selectors.ps1`
- [ ] **Step 1: Create verification script**
Create `scripts/verify-n13-uia-selectors.ps1`:
```powershell
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $repo
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "go was not found. Run this verifier in a Go 1.23.x environment."
}
Write-Host "## go test ./internal/uiaselector"
go test ./internal/uiaselector -count=1
if ($LASTEXITCODE -ne 0) {
throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
}
Write-Host "## boundary grep"
$forbidden = @(
"send_after_approval",
"send_file_after_approval",
"receive_file_after_approval",
"open_conversation",
"ValuePattern.SetValue",
"InvokePattern"
)
$files = @(
"internal\uiaselector\catalog.go",
"internal\uiaselector\matcher.go"
)
foreach ($term in $forbidden) {
$hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
if ($hit) {
throw "forbidden action term found in selector implementation: $term"
}
}
Write-Host "N13 UIA selector verification passed."
```
- [ ] **Step 2: Run script and verify it passes**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
```
Expected in Go-enabled environment:
```text
## go test ./internal/uiaselector
ok isphere-ai-bridge/internal/uiaselector
## boundary grep
N13 UIA selector verification passed.
```
If `go` is unavailable, expected failure is:
```text
go was not found. Run this verifier in a Go 1.23.x environment.
```
- [ ] **Step 3: Commit verification script**
```powershell
git add scripts\verify-n13-uia-selectors.ps1
git commit -m "test: add N13 selector verifier"
```
## Task 6: Final verification and checkpoint
**Files:**
- No new files.
- Verify: `internal/uiaselector/*`, `scripts/verify-n13-uia-selectors.ps1`
- [ ] **Step 1: Run package tests**
Run:
```powershell
go test ./internal/uiaselector -count=1
```
Expected:
```text
ok isphere-ai-bridge/internal/uiaselector
```
- [ ] **Step 2: Run repository tests if Go is available**
Run:
```powershell
go test ./...
```
Expected: PASS for all packages.
If the environment lacks Go, do not mark implementation complete. Move to a Go 1.23.x environment and rerun.
- [ ] **Step 3: Run N13 verifier**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
```
Expected: PASS in Go-enabled environment.
- [ ] **Step 4: Check no MCP/helper action surface changed**
Run:
```powershell
git diff --name-only origin/main..HEAD
```
Expected changed implementation files are limited to:
```text
internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/dump.go
internal/uiaselector/dump_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1
```
If `native/ISphereWinHelper/Program.cs`, `internal/tools/winhelper.go`, or `cmd/isphere-mcp/main.go` appears, stop and review because N13 is not supposed to add live tools.
- [ ] **Step 5: Commit final checkpoint if needed**
If any verification-only documentation changes were made, run:
```powershell
git add <changed verification docs>
git commit -m "docs: record N13 selector verification"
```
If no files changed, do not create an empty commit.
## Self-review checklist
- Spec coverage: this plan covers selector catalog, fixture loading, read-only matcher, no visible text in result, and boundary verification.
- Boundary: no helper op, no MCP tool, no click/input/send/file/conversation behavior.
- TDD: every implementation task starts with failing tests before code.
- Commit cadence: fixture, catalog, loader, matcher, verifier are separate commits.
- Environment caveat: Go 1.23.x is required for implementation verification.

View File

@@ -0,0 +1,597 @@
# N13 UIA Selector Catalog Design
日期2026-07-09
## 1. 目标
N13 的目标是基于已 ACCEPT 的 N12R live UIA 证据,设计一套只读 selector catalog 和定位验证流程。
本阶段只做设计,不新增 helper op不新增 MCP tool不点击、不输入、不打开会话、不发送消息、不传输文件。
N13 完成后,下一步才可以写独立实现计划,考虑是否新增只读定位验证能力。
## 2. 输入证据
N12R 返回包已通过外网 validator
```text
runs/internal-sandbox-live-capture-reports/2026-07-09-retry-2/2026-07-08-internal-sandbox-a-validation.md
Decision: ACCEPT
PASS: 36
WARN: 1
FAIL: 0
```
使用的 UIA dump
```text
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main.json
```
关键事实:
```text
root control_type: Window
root automation_id: frmMain
root class_name: WindowsForms10.Window.8.app.0.d3a00f_r29_ad1
framework_id: WinForm
UIA node count: 66
max_depth: 7
```
本设计只引用结构性字段,不依赖敏感可见文本。
## 3. 非目标
N13 不做以下事项:
```text
自动登录
搜索联系人
打开会话
点击控件
输入文本
发送消息
发送文件
接收文件
读取或导出聊天内容
注入进程
hook
读取目标进程内存
绕过终端安全/安防
```
N13 也不把 `search_contacts``open_conversation``read_latest_messages` 作为当前实现目标。这些只能在 selector catalog 稳定、只读定位验证通过、并形成新的审批设计后再讨论。
## 4. 推荐方案
采用“selector catalog + 只读定位验证”方案。
核心思想:
1. 用 N12R UIA dump 建立一份 selector catalog。
2. 每个 selector 都只描述如何识别一个 UIA 节点。
3. selector 的初始用途只允许 read-only locate。
4. 后续如果实现 `locate_uia`,也只能返回匹配节点摘要,不执行任何 UI 动作。
5. 任何真实动作类能力必须另写设计、计划和审批边界。
## 5. Selector 数据模型
建议 selector 条目使用以下字段:
```json
{
"id": "main_window",
"description": "主窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "frmMain",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"path_hint": "root"
},
"fallback": [
{
"control_type": "Window",
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"]
}
],
"stability_score": 95,
"evidence": {
"capture_id": "2026-07-08-internal-sandbox-a",
"uia_path": "root"
}
}
```
字段含义:
- `id`:稳定、短小、英文蛇形命名。
- `description`:中文说明。
- `allowed_use`N13 固定为 `read_only_locate`
- `required`:必须同时满足的字段。
- `preferred`:加分项,用于减少误匹配。
- `fallback`:主 selector 失效时的只读匹配策略。
- `stability_score`0 到 100越高越稳定。
- `evidence`:来源证据路径和 UIA 路径。
## 6. 初始 selector catalog
### 6.1 主窗口:`main_window`
```json
{
"id": "main_window",
"description": "iSphere / IMPlatformClient 主窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "frmMain",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"path_hint": "root"
},
"fallback": [
{
"control_type": "Window",
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"]
}
],
"stability_score": 95,
"evidence": {
"uia_path": "root"
}
}
```
理由:`automation_id=frmMain` 出现在根节点root 是 N12R 已确认主窗口,稳定性最高。
### 6.2 主 tab 容器:`main_plugin_tab`
```json
{
"id": "main_plugin_tab",
"description": "主功能 tab 容器",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucPluginTabControl1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/3",
"ancestor_selector": "main_window"
},
"fallback": [
{
"ancestor_selector": "main_window",
"control_type": "Pane",
"child_automation_id_contains": ["FrmHomePage", "ucTabPage"]
}
],
"stability_score": 90,
"evidence": {
"uia_path": "root/3"
}
}
```
理由该节点直接挂在主窗口下automation_id 明确,子结构包含首页和 tab 内容区。
### 6.3 首页窗口:`home_page_window`
```json
{
"id": "home_page_window",
"description": "首页内容窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "FrmHomePage",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/3/0/0",
"ancestor_selector": "main_plugin_tab"
},
"fallback": [
{
"ancestor_selector": "main_plugin_tab",
"control_type": "Window",
"class_name_prefix": "WindowsForms10.Window.8.app.0"
}
],
"stability_score": 82,
"evidence": {
"uia_path": "root/3/0/0"
}
}
```
理由:该节点可用于确认当前主 UI 内容区存在,但不用于动作。
### 6.4 tab 内容窗口:`tab_page_content`
```json
{
"id": "tab_page_content",
"description": "tab 页内容窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "FrmTabPageContent",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/3/1/0",
"ancestor_selector": "main_plugin_tab"
},
"fallback": [
{
"ancestor_selector": "main_plugin_tab",
"control_type": "Window",
"child_control_type_contains": ["TitleBar"]
}
],
"stability_score": 82,
"evidence": {
"uia_path": "root/3/1/0"
}
}
```
理由:可作为后续只读结构检查的内容区域锚点。
### 6.5 左侧面板:`left_panel`
```json
{
"id": "left_panel",
"description": "左侧主面板",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "panelLeft",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4",
"ancestor_selector": "main_window"
},
"fallback": [
{
"ancestor_selector": "main_window",
"control_type": "Pane",
"child_automation_id_contains": ["ucPnlRoster1", "ucFuzzyQuery1"]
}
],
"stability_score": 92,
"evidence": {
"uia_path": "root/4"
}
}
```
理由:该节点是联系人/搜索相关区域的稳定父节点。
### 6.6 组织/联系人容器:`roster_panel`
```json
{
"id": "roster_panel",
"description": "组织/联系人容器外层",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucPnlRoster1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/0/0",
"ancestor_selector": "left_panel"
},
"fallback": [
{
"ancestor_selector": "left_panel",
"control_type": "Pane",
"child_automation_id_contains": ["ucRoster1"]
}
],
"stability_score": 88,
"evidence": {
"uia_path": "root/4/0/0"
}
}
```
理由:该节点是 roster 结构外层,可以只读验证联系人区域是否存在。
### 6.7 组织/联系人列表:`roster_view`
```json
{
"id": "roster_view",
"description": "组织/联系人列表区域",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucRoster1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/0/0/0",
"ancestor_selector": "roster_panel"
},
"fallback": [
{
"ancestor_selector": "roster_panel",
"control_type": "Pane",
"class_name_prefix": "WindowsForms10.Window.8.app.0"
}
],
"stability_score": 86,
"evidence": {
"uia_path": "root/4/0/0/0"
}
}
```
理由:该节点本次 child_count 为 0不能证明列表项结构只能作为区域存在性 selector。
### 6.8 搜索组件外层:`fuzzy_query_panel`
```json
{
"id": "fuzzy_query_panel",
"description": "模糊搜索组件外层",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucFuzzyQuery1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/1/0",
"ancestor_selector": "left_panel"
},
"fallback": [
{
"ancestor_selector": "left_panel",
"control_type": "Pane",
"child_automation_id_contains": ["FrmFuzzyQuery"]
}
],
"stability_score": 90,
"evidence": {
"uia_path": "root/4/1/0"
}
}
```
理由:该组件是搜索输入框的稳定父级。
### 6.9 搜索窗口:`fuzzy_query_window`
```json
{
"id": "fuzzy_query_window",
"description": "模糊搜索内部窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "FrmFuzzyQuery",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/1/0/0",
"ancestor_selector": "fuzzy_query_panel"
},
"fallback": [
{
"ancestor_selector": "fuzzy_query_panel",
"control_type": "Pane",
"child_automation_id_contains": ["skinAlphaTxt"]
}
],
"stability_score": 88,
"evidence": {
"uia_path": "root/4/1/0/0"
}
}
```
理由:该节点包含搜索输入框,是后续只读定位验证中的关键父节点。
### 6.10 搜索输入框:`fuzzy_search_edit`
```json
{
"id": "fuzzy_search_edit",
"description": "模糊搜索输入框",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "skinAlphaTxt",
"control_type": "Edit",
"framework_id": "WinForm"
},
"preferred": {
"class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1",
"path_hint": "root/4/1/0/0/2",
"ancestor_selector": "fuzzy_query_window"
},
"fallback": [
{
"ancestor_selector": "fuzzy_query_window",
"control_type": "Edit",
"class_name_prefix": "WindowsForms10.EDIT.app.0"
}
],
"stability_score": 93,
"evidence": {
"uia_path": "root/4/1/0/0/2"
}
}
```
理由:该节点 automation_id、control_type 和 class_name 都明确。N13 只允许定位它,不允许输入搜索词。
## 7. 只读定位验证规则
后续如果实现定位验证,只能做以下事情:
1. 接收 selector id。
2. 读取当前窗口 UIA 树。
3. 按 selector required 字段过滤节点。
4. 用 preferred 字段排序或加分。
5. 如果主 selector 无匹配,再尝试 fallback。
6. 返回节点摘要:`selector_id``matched``match_count``path``control_type``automation_id``class_name``framework_id``is_enabled``is_offscreen``bounds`
禁止返回完整 visible text。可见文本只能返回以下派生信息
```text
has_name: true/false
name_length: number
```
禁止执行:
```text
InvokePattern
ValuePattern.SetValue
SelectionItemPattern.Select
ExpandCollapsePattern.Expand
点击
键盘输入
剪贴板操作
窗口激活
焦点切换
```
## 8. 稳定性评分规则
建议评分:
```text
95-100: root 或唯一主窗口级 anchor
90-94: automation_id 明确、control_type 明确、父子路径稳定
80-89: automation_id 明确,但可能依赖 tab 或运行状态
70-79: 缺 automation_id但 class/control/path 组合可用
0-69: 不进入首批 catalog
```
本次首批 selector 全部要求 automation_id 明确。
## 9. 错误处理设计
只读定位验证应返回结构化结果,不抛出模糊错误。
建议结果:
```json
{
"ok": true,
"selector_id": "fuzzy_search_edit",
"matched": true,
"match_count": 1,
"matches": [
{
"path": "root/4/1/0/0/2",
"control_type": "Edit",
"automation_id": "skinAlphaTxt",
"class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1",
"framework_id": "WinForm",
"has_name": true,
"name_length": 0
}
],
"warnings": []
}
```
未匹配时:
```json
{
"ok": true,
"selector_id": "fuzzy_search_edit",
"matched": false,
"match_count": 0,
"matches": [],
"warnings": ["selector_not_found"]
}
```
selector 定义非法时:
```json
{
"ok": false,
"selector_id": "fuzzy_search_edit",
"error": {
"code": "INVALID_SELECTOR",
"message": "selector required fields are incomplete"
}
}
```
## 10. 测试策略
N13 设计阶段不写实现代码,但后续实现计划必须包含以下测试:
1. 从 N12R dump fixture 读取 UIA 树。
2. 验证 `main_window` 匹配唯一 root。
3. 验证 `left_panel` 匹配 `root/4`
4. 验证 `fuzzy_search_edit` 匹配 `root/4/1/0/0/2`
5. 验证 selector 不返回完整 `name` 文本,只返回 `has_name``name_length`
6. 验证缺失 selector 返回 `matched=false`,不报崩溃。
7. 验证动作模式字段或动作请求被拒绝。
8. 验证 catalog 中不包含 `send``upload``download``conversation``login` 等动作词作为工具名。
## 11. 文件规划
如果后续进入实现计划,建议新增或修改:
```text
internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1
```
暂不修改:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
```
除非新的实现计划明确批准新增只读定位工具,否则 Go MCP 仍只暴露现有四个只读工具。
## 12. N13 验收条件
N13 设计可接受条件:
1. selector catalog 只依赖结构性 UIA 字段。
2. 不依赖敏感可见文本。
3. 首批 selector 都能追溯到 N12R ACCEPT dump 的路径。
4. allowed_use 全部是 `read_only_locate`
5. 文档明确禁止点击、输入、打开会话、发送和文件操作。
6. 后续实现计划必须 TDD并先使用 fixture 测试 selector matcher。
## 13. 推荐下一步
下一步不是直接写代码,而是写一份 N13 implementation plan。
该计划应先实现纯离线 selector matcher对 N12R UIA dump fixture 做只读匹配测试。
只有离线 selector matcher 通过后,才讨论是否新增 live `locate_uia` helper/MCP 只读工具。

View File

@@ -0,0 +1,148 @@
package uiaselector
const AllowedUseReadOnlyLocate = "read_only_locate"
type Selector struct {
ID string `json:"id"`
Description string `json:"description"`
AllowedUse string `json:"allowed_use"`
Required Criteria `json:"required"`
Preferred PreferredCriteria `json:"preferred"`
Fallback []FallbackCriteria `json:"fallback"`
StabilityScore int `json:"stability_score"`
Evidence Evidence `json:"evidence"`
}
type Criteria struct {
AutomationID string `json:"automation_id,omitempty"`
ControlType string `json:"control_type,omitempty"`
FrameworkID string `json:"framework_id,omitempty"`
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type PreferredCriteria struct {
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
PathHint string `json:"path_hint,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type FallbackCriteria struct {
AncestorID string `json:"ancestor_selector,omitempty"`
ControlType string `json:"control_type,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"`
ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"`
}
type Evidence struct {
CaptureID string `json:"capture_id"`
UIAPath string `json:"uia_path"`
}
func DefaultCatalog() []Selector {
return []Selector{
{
ID: "main_window",
Description: "iSphere / IMPlatformClient 主窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
StabilityScore: 95,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
},
{
ID: "main_plugin_tab",
Description: "主功能 tab 容器",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
},
{
ID: "home_page_window",
Description: "首页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
},
{
ID: "tab_page_content",
Description: "tab 页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
},
{
ID: "left_panel",
Description: "左侧主面板",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
StabilityScore: 92,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
},
{
ID: "roster_panel",
Description: "组织/联系人容器外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
},
{
ID: "roster_view",
Description: "组织/联系人列表区域",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 86,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
},
{
ID: "fuzzy_query_panel",
Description: "模糊搜索组件外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
},
{
ID: "fuzzy_query_window",
Description: "模糊搜索内部窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
},
{
ID: "fuzzy_search_edit",
Description: "模糊搜索输入框",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
StabilityScore: 93,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
},
}
}

View File

@@ -0,0 +1,60 @@
package uiaselector
import (
"strings"
"testing"
)
func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
catalog := DefaultCatalog()
wantIDs := []string{
"main_window",
"main_plugin_tab",
"home_page_window",
"tab_page_content",
"left_panel",
"roster_panel",
"roster_view",
"fuzzy_query_panel",
"fuzzy_query_window",
"fuzzy_search_edit",
}
byID := map[string]Selector{}
for _, selector := range catalog {
byID[selector.ID] = selector
}
for _, id := range wantIDs {
selector, ok := byID[id]
if !ok {
t.Fatalf("DefaultCatalog missing selector %q", id)
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
}
if selector.Required.AutomationID == "" {
t.Fatalf("selector %q must require automation_id", id)
}
if selector.Required.ControlType == "" {
t.Fatalf("selector %q must require control_type", id)
}
if selector.StabilityScore < 80 {
t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
}
}
}
func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
for _, selector := range DefaultCatalog() {
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
}
lower := strings.ToLower(selector.ID + " " + selector.Description)
for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
}
}
}
}

View File

@@ -0,0 +1,109 @@
package uiaselector
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
)
type DumpResponse struct {
OK bool `json:"ok"`
Data *DumpData `json:"data"`
Error any `json:"error"`
}
type DumpData struct {
Root *Node `json:"root"`
}
type Node struct {
Name string `json:"name"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
IsEnabled *bool `json:"is_enabled"`
IsOffscreen *bool `json:"is_offscreen"`
Bounds any `json:"bounds"`
Children []Node `json:"children"`
}
func LoadDumpFile(path string) (*Node, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
content = normalizeDumpJSON(content)
var response DumpResponse
if err := json.Unmarshal(content, &response); err != nil {
return nil, err
}
if !response.OK {
return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
}
if response.Data == nil || response.Data.Root == nil {
return nil, fmt.Errorf("dump response has no data.root")
}
return response.Data.Root, nil
}
func CountNodes(root *Node) int {
if root == nil {
return 0
}
count := 1
for i := range root.Children {
count += CountNodes(&root.Children[i])
}
return count
}
func normalizeDumpJSON(content []byte) []byte {
content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF})
source := string(content)
var out strings.Builder
out.Grow(len(source))
inString := false
escaped := false
for i := 0; i < len(source); {
ch := source[i]
if inString {
out.WriteByte(ch)
if escaped {
escaped = false
} else if ch == '\\' {
escaped = true
} else if ch == '"' {
inString = false
}
i++
continue
}
if ch == '"' {
inString = true
out.WriteByte(ch)
i++
continue
}
switch {
case strings.HasPrefix(source[i:], "-Infinity"):
out.WriteString("null")
i += len("-Infinity")
case strings.HasPrefix(source[i:], "Infinity"):
out.WriteString("null")
i += len("Infinity")
case strings.HasPrefix(source[i:], "NaN"):
out.WriteString("null")
i += len("NaN")
default:
out.WriteByte(ch)
i++
}
}
return []byte(out.String())
}

View File

@@ -0,0 +1,19 @@
package uiaselector
import "testing"
func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
if root.AutomationID != "frmMain" {
t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
}
if root.ControlType != "Window" {
t.Fatalf("root control_type = %q, want Window", root.ControlType)
}
if got := CountNodes(root); got != 66 {
t.Fatalf("CountNodes = %d, want 66", got)
}
}

View File

@@ -0,0 +1,186 @@
package uiaselector
import "strings"
type MatchResult struct {
OK bool `json:"ok"`
SelectorID string `json:"selector_id"`
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
Matches []NodeMatch `json:"matches"`
Warnings []string `json:"warnings"`
Error MatchError `json:"error,omitempty"`
}
type MatchError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type NodeMatch struct {
Path string `json:"path"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
HasName bool `json:"has_name"`
NameLength int `json:"name_length"`
Name string `json:"-"`
}
type nodeRef struct {
path string
node *Node
}
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
selector, ok := findSelector(catalog, selectorID)
if !ok {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
}
refs := flatten(root)
matches := matchCriteria(refs, selector.Required)
if len(matches) == 0 {
for _, fallback := range selector.Fallback {
matches = matchFallback(refs, fallback)
if len(matches) > 0 {
break
}
}
}
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
for _, ref := range matches {
result.Matches = append(result.Matches, summarizeNode(ref))
}
if !result.Matched {
result.Warnings = append(result.Warnings, "selector_not_found")
}
return result
}
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
for _, selector := range catalog {
if selector.ID == selectorID {
return selector, true
}
}
return Selector{}, false
}
func flatten(root *Node) []nodeRef {
refs := []nodeRef{}
var walk func(node *Node, path string)
walk = func(node *Node, path string) {
if node == nil {
return
}
refs = append(refs, nodeRef{path: path, node: node})
for i := range node.Children {
walk(&node.Children[i], path+"/"+itoa(i))
}
}
walk(root, "root")
return refs
}
func itoa(value int) string {
if value == 0 {
return "0"
}
digits := []byte{}
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value = value / 10
}
return string(digits)
}
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
continue
}
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
continue
}
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
continue
}
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
continue
}
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
continue
}
matches = append(matches, ref)
}
return matches
}
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
continue
}
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
continue
}
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
continue
}
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
continue
}
matches = append(matches, ref)
}
return matches
}
func hasChildAutomationIDs(node *Node, ids []string) bool {
for _, id := range ids {
found := false
for i := range node.Children {
if node.Children[i].AutomationID == id {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func hasChildControlTypes(node *Node, controlTypes []string) bool {
for _, controlType := range controlTypes {
found := false
for i := range node.Children {
if node.Children[i].ControlType == controlType {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func summarizeNode(ref nodeRef) NodeMatch {
return NodeMatch{
Path: ref.path,
ControlType: ref.node.ControlType,
AutomationID: ref.node.AutomationID,
ClassName: ref.node.ClassName,
FrameworkID: ref.node.FrameworkID,
HasName: ref.node.Name != "",
NameLength: len(ref.node.Name),
}
}

View File

@@ -0,0 +1,73 @@
package uiaselector
import "testing"
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
tests := []struct {
selectorID string
path string
control string
autoID string
}{
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
}
for _, tt := range tests {
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
if !result.OK {
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
}
if !result.Matched {
t.Fatalf("%s matched=false", tt.selectorID)
}
if result.MatchCount != 1 {
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
}
match := result.Matches[0]
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
t.Fatalf("%s match = %#v", tt.selectorID, match)
}
}
}
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "main_window")
if !result.OK || !result.Matched || len(result.Matches) != 1 {
t.Fatalf("unexpected result: %#v", result)
}
match := result.Matches[0]
if match.Name != "" {
t.Fatalf("match returned visible name text %q", match.Name)
}
if !match.HasName {
t.Fatalf("main_window should report has_name=true")
}
if match.NameLength == 0 {
t.Fatalf("main_window should report non-zero name_length")
}
}
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
if result.OK {
t.Fatalf("OK=true for missing selector: %#v", result)
}
if result.Error.Code != "UNKNOWN_SELECTOR" {
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -162,6 +162,16 @@ try {
Assert-Contains $result.Output "Decision: REJECT" "Expected reject decision in output."
}
Run-Test "accepts harmless process names that contain hook as a substring" {
$pkg = Join-Path $tempRoot "harmless-hook-substring"
$report = Join-Path $tempRoot "harmless-hook-substring-report.md"
New-GoodPackage $pkg
Write-Utf8Text (Join-Path $pkg "metadata\process-list.txt") "zfhookupdat 3240"
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -eq 0) "Expected validator success for harmless process-name substring, got exit $($result.ExitCode): $($result.Output)"
Assert-Contains $result.Output "Decision: ACCEPT" "Expected accept decision in output."
}
Run-Test "rejects report paths inside the evidence package" {
$pkg = Join-Path $tempRoot "report-inside-package"
New-GoodPackage $pkg

View File

@@ -318,6 +318,9 @@ try {
}
foreach ($relative in ($requiredFiles + $optionalFiles)) {
if ($relative -eq "metadata/process-list.txt") {
continue
}
$path = Join-PackagePath $packageFullPath $relative
if (Test-Path -LiteralPath $path -PathType Leaf) {
$text = if ($rawFiles.ContainsKey($relative)) { $rawFiles[$relative] } else { Read-TextFile $packageFullPath $relative }

View File

@@ -0,0 +1,37 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $repo
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "go was not found. Run this verifier in a Go 1.23.x environment."
}
Write-Host "## go test ./internal/uiaselector"
go test ./internal/uiaselector -count=1
if ($LASTEXITCODE -ne 0) {
throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
}
Write-Host "## boundary grep"
$forbidden = @(
"send_after_approval",
"send_file_after_approval",
"receive_file_after_approval",
"open_conversation",
"ValuePattern.SetValue",
"InvokePattern"
)
$files = @(
"internal\uiaselector\catalog.go",
"internal\uiaselector\matcher.go"
)
foreach ($term in $forbidden) {
$hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
if ($hit) {
throw "forbidden action term found in selector implementation: $term"
}
}
Write-Host "N13 UIA selector verification passed."