From 1be056144ddfb94487f97744a765b288a8a33743 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:13:07 +0800 Subject: [PATCH 01/23] docs: design Arc authorization diagnostics --- .../2026-08-03-arc-diagnostics-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md diff --git a/docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md b/docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md new file mode 100644 index 0000000..76935ee --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-arc-diagnostics-design.md @@ -0,0 +1,114 @@ +# Arc 授权窗口诊断设计 + +## 目标 + +为密码桥增加一次性、只读的浏览器授权窗口诊断能力,明确 Arc 失败发生在应用规则、CoreGraphics 窗口预筛选、Accessibility 授权上下文还是验证码输入框识别阶段。 + +诊断功能不改变现有扫描、验证码读取或自动填入逻辑,也不尝试修复 Arc 兼容性。它只收集定位根因所需的最小元数据。 + +## 用户体验 + +菜单增加“诊断当前授权窗口”按钮。用户在 Arc 的 iCloud 密码授权界面保持可见时点击该按钮,应用执行一次诊断并在菜单内显示摘要。 + +诊断完成后显示: + +- 结论:通过、应用未运行、未发现候选窗口、AX 上下文不匹配或未识别输入框。 +- 最近一次诊断时间。 +- “复制诊断报告”按钮。 + +诊断运行期间不激活浏览器、不聚焦输入框、不发送键盘事件,也不读取 Apple“密码”中的验证码。 + +## 诊断数据流 + +### 1. 应用规则层 + +记录当前规则模式、目标应用的显示名、bundle ID、PID、运行状态和 activation policy。诊断范围与现有应用规则一致,确保报告能区分“Arc 未运行”与“Arc 被规则排除”。 + +### 2. CoreGraphics 窗口层 + +枚举目标应用当前可见的普通窗口,记录: + +- PID 和窗口编号; +- 窗口尺寸; +- 脱敏标题; +- 是否通过现有 `isICloudPasswordWindowTitle` 判断。 + +即使窗口未通过标题规则,也要保留一条诊断记录,以确认 Arc 实际暴露的窗口标题。 + +### 3. Accessibility 层 + +对目标应用的 AX 窗口执行现有深度和节点数限制下的只读遍历,记录: + +- AX 窗口数量和脱敏标题; +- 是否观察到 `moz-extension://` 或 `chrome-extension://`; +- 是否观察到 `/page_popup.html`; +- 是否命中 iCloud、自动填充和验证码语义; +- `AXTextField`、`AXTextArea`、`AXSecureTextField` 及其他可编辑角色的数量; +- 当前稳定 popup 签名和授权上下文判断结果。 + +诊断器复用现有解析规则,但不复用只返回成功目标的接口,以便保留每道失败原因。 + +### 4. 结论层 + +按最早失败边界给出稳定的机器可读代码和中文说明: + +- `application_not_running` +- `application_rejected_by_policy` +- `no_visible_windows` +- `window_title_mismatch` +- `ax_windows_unavailable` +- `authorization_context_mismatch` +- `input_roles_unrecognized` +- `target_recognized` + +报告同时列出后续各层观察结果,避免单一错误文案掩盖多个结构差异。 + +## 组件设计 + +新增 `BrowserDiagnostics.swift`: + +- `BrowserDiagnosticReport`:完整诊断结果和纯文本渲染。 +- `ApplicationDiagnostic`、`WindowDiagnostic`、`AccessibilityDiagnostic`:各边界的结构化结果。 +- `BrowserDiagnostics.run(policy:)`:只读执行一次诊断。 +- `DiagnosticRedactor`:负责标题、URL和数字脱敏。 + +`BridgeModel` 仅负责触发诊断、持有最近一次内存报告和复制操作状态。`BridgeApp` 只展示摘要和按钮,不包含判断逻辑。 + +## 隐私与脱敏 + +- 不调用 `PasswordCodeReader`,不读取或输出验证码。 +- 不记录 Apple“密码”窗口正文。 +- 不将完整 AX 文本写入报告。 +- 来自窗口标题、URL或 AX 文本的连续或分隔六位数字统一替换为 ``;这些文本中的其他连续数字也按通用数字标记脱敏。 +- PID、窗口编号、尺寸、计数和时间戳作为有标签的系统诊断字段保留,不与窗口或 AX 文本拼接。 +- 扩展 URL 仅保留 scheme 和末尾路径,例如 `chrome-extension:///page_popup.html`。 +- 窗口标题只保留识别所需的短文本,并在输出前执行数字与 URL 脱敏。 +- 报告仅存在内存和系统剪贴板;只有用户点击“复制诊断报告”时才进入剪贴板。 +- 不写文件、不使用 `print`、`NSLog` 或持久化日志。 + +## 错误处理 + +权限不足、窗口在诊断过程中关闭、AX 属性不可读都转换为报告中的观察项,不令应用崩溃。诊断按钮可重复运行,新的结果原子替换旧报告。 + +诊断期间按钮显示进行中并避免并发执行;它不阻止既有自动扫描,但所有诊断操作必须保持只读。 + +## 测试 + +新增单元测试覆盖: + +- Arc bundle ID 能按白名单进入诊断范围; +- 每个失败边界生成正确结论代码; +- iCloud 标题规则通过与失败的报告差异; +- Chromium 扩展 URL 被识别且扩展 ID 被脱敏; +- 六位码、分隔六位码和一般数字不会出现在复制报告中; +- 单字段、六字段和未知可编辑角色得到不同诊断结果; +- 报告不包含原始 AX 全文。 + +现有自动填入测试保持不变。构建验收运行 `swift test --disable-sandbox`,并在 Arc 授权窗口上人工确认诊断报告能够指出具体失败边界。 + +## 非目标 + +- 本阶段不修改授权窗口匹配规则。 +- 不加入 Arc 专用标题、URL或输入框兼容逻辑。 +- 不新增磁盘日志、遥测或网络上传。 +- 不自动收集或提交诊断报告。 From 99d6f9731b8f30b4bc6fc2f9a4992e845d6c7f4b Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:00 +0800 Subject: [PATCH 02/23] docs: plan Arc authorization diagnostics --- .../plans/2026-08-03-arc-diagnostics.md | 913 ++++++++++++++++++ 1 file changed, 913 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-arc-diagnostics.md diff --git a/docs/superpowers/plans/2026-08-03-arc-diagnostics.md b/docs/superpowers/plans/2026-08-03-arc-diagnostics.md new file mode 100644 index 0000000..5ae1aa8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-arc-diagnostics.md @@ -0,0 +1,913 @@ +# Arc Authorization Diagnostics 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:** Add a privacy-preserving, one-shot in-app diagnostic report that identifies the exact recognition boundary preventing Arc's iCloud Passwords authorization window from being autofilled. + +**Architecture:** Add a focused `BrowserDiagnostics.swift` unit containing immutable observations, deterministic conclusion evaluation, redaction, plain-text rendering, and a live macOS collector. `BridgeModel` owns only the latest in-memory report and actions; `BridgeApp` renders the summary and copy button. Existing autofill matching and code-reading behavior remains unchanged. + +**Tech Stack:** Swift 5.10, SwiftUI, AppKit, ApplicationServices Accessibility API, CoreGraphics window APIs, XCTest, Swift Package Manager. + +--- + +## File map + +- Create `Sources/ApplePasswordBridge/BrowserDiagnostics.swift`: diagnostic data model, conclusion evaluator, redactor, report renderer, and read-only live collector. +- Create `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift`: deterministic evaluator, privacy, rendering, Arc allowlist, URL, and input-role tests. +- Modify `Sources/ApplePasswordBridge/BridgeModel.swift`: published diagnostic state, trigger method, and explicit copy action. +- Modify `Sources/ApplePasswordBridge/BridgeApp.swift`: diagnostic controls and most-recent result presentation. +- Keep `Sources/ApplePasswordBridge/FirefoxAutofill.swift`, `PasswordCodeReader.swift`, and `CodeParser.swift` behavior unchanged. + +### Task 1: Define diagnostic observations and conclusion evaluation + +**Files:** +- Create: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Create: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing evaluator tests** + +Create the test file with a helper and one test per recognition boundary: + +```swift +import XCTest +@testable import ApplePasswordBridge + +final class BrowserDiagnosticsTests: XCTestCase { + private func application( + running: Bool = true, + permitted: Bool = true, + windows: [DiagnosticWindow] = [], + accessibilityWindows: [DiagnosticAccessibilityWindow] = [] + ) -> DiagnosticApplication { + DiagnosticApplication( + displayName: "Arc", + bundleIdentifier: "company.thebrowser.Browser", + processIdentifier: running ? 42 : nil, + activationPolicy: running ? "regular" : nil, + permitted: permitted, + windows: windows, + accessibilityWindows: accessibilityWindows + ) + } + + private func recognizedApplication(roleCounts: [String: Int]) -> DiagnosticApplication { + let window = DiagnosticWindow( + number: 7, + width: 600, + height: 500, + title: "iCloud Passwords", + titleMatches: true + ) + let axWindow = DiagnosticAccessibilityWindow( + title: "iCloud Passwords", + extensionURLSummary: "chrome-extension:///page_popup.html", + hasExtensionURL: true, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: roleCounts, + stablePopupSignature: true, + authorizationContextMatches: true + ) + return application(windows: [window], accessibilityWindows: [axWindow]) + } + + func testConclusionReportsApplicationNotRunning() { + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(running: false)]), + .applicationNotRunning + ) + } + + func testConclusionReportsPolicyRejection() { + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(permitted: false)]), + .applicationRejectedByPolicy + ) + } + + func testConclusionReportsNoVisibleWindows() { + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application()]), + .noVisibleWindows + ) + } + + func testConclusionReportsTitleMismatchBeforeAXMismatch() { + let window = DiagnosticWindow(number: 7, width: 600, height: 500, title: "Arc", titleMatches: false) + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(windows: [window])]), + .windowTitleMismatch + ) + } + + func testConclusionReportsUnavailableAccessibilityWindows() { + let window = DiagnosticWindow(number: 7, width: 600, height: 500, title: "iCloud Passwords", titleMatches: true) + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(windows: [window])]), + .accessibilityWindowsUnavailable + ) + } + + func testConclusionReportsAuthorizationContextMismatch() { + let window = DiagnosticWindow(number: 7, width: 600, height: 500, title: "iCloud Passwords", titleMatches: true) + let axWindow = DiagnosticAccessibilityWindow( + title: "Arc", + extensionURLSummary: nil, + hasExtensionURL: false, + hasPopupPath: false, + hasICloudIdentity: false, + hasAutofillTerms: false, + hasVerificationCodeTerms: false, + roleCounts: [:], + stablePopupSignature: false, + authorizationContextMatches: false + ) + XCTAssertEqual( + BrowserDiagnosticConclusion.evaluate([application(windows: [window], accessibilityWindows: [axWindow])]), + .authorizationContextMismatch + ) + } + + func testConclusionReportsUnrecognizedInputRoles() { + let report = recognizedApplication(roleCounts: ["AXGroup": 6]) + XCTAssertEqual(BrowserDiagnosticConclusion.evaluate([report]), .inputRolesUnrecognized) + } + + func testConclusionReportsRecognizedTarget() { + let report = recognizedApplication(roleCounts: ["AXTextField": 6]) + XCTAssertEqual(BrowserDiagnosticConclusion.evaluate([report]), .targetRecognized) + } +} +``` + +Do not use live system APIs in these tests. + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +swift test --disable-sandbox --filter BrowserDiagnosticsTests +``` + +Expected: compilation fails because the diagnostic types do not exist. + +- [ ] **Step 3: Add the minimal immutable types and evaluator** + +Create `BrowserDiagnostics.swift` with these public-to-target internal definitions: + +```swift +import AppKit +import ApplicationServices +import CoreGraphics + +enum BrowserDiagnosticConclusion: String, Equatable { + case applicationNotRunning = "application_not_running" + case applicationRejectedByPolicy = "application_rejected_by_policy" + case noVisibleWindows = "no_visible_windows" + case windowTitleMismatch = "window_title_mismatch" + case accessibilityWindowsUnavailable = "ax_windows_unavailable" + case authorizationContextMismatch = "authorization_context_mismatch" + case inputRolesUnrecognized = "input_roles_unrecognized" + case targetRecognized = "target_recognized" + + static func evaluate(_ applications: [DiagnosticApplication]) -> Self { + guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } + let permitted = applications.filter { $0.processIdentifier != nil && $0.permitted } + guard !permitted.isEmpty else { return .applicationRejectedByPolicy } + guard permitted.contains(where: { !$0.windows.isEmpty }) else { return .noVisibleWindows } + guard permitted.flatMap(\.windows).contains(where: \.titleMatches) else { return .windowTitleMismatch } + let axWindows = permitted.flatMap(\.accessibilityWindows) + guard !axWindows.isEmpty else { return .accessibilityWindowsUnavailable } + let matching = axWindows.filter { $0.stablePopupSignature || $0.authorizationContextMatches } + guard !matching.isEmpty else { return .authorizationContextMismatch } + guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } + return .targetRecognized + } +} + +struct DiagnosticWindow: Equatable { + let number: UInt32 + let width: Int + let height: Int + let title: String + let titleMatches: Bool +} + +struct DiagnosticAccessibilityWindow: Equatable { + let title: String + let extensionURLSummary: String? + let hasExtensionURL: Bool + let hasPopupPath: Bool + let hasICloudIdentity: Bool + let hasAutofillTerms: Bool + let hasVerificationCodeTerms: Bool + let roleCounts: [String: Int] + let stablePopupSignature: Bool + let authorizationContextMatches: Bool + + var supportedInputCount: Int { + [kAXTextFieldRole as String, kAXTextAreaRole as String, "AXSecureTextField"] + .reduce(0) { $0 + (roleCounts[$1] ?? 0) } + } +} + +struct DiagnosticApplication: Equatable { + let displayName: String + let bundleIdentifier: String + let processIdentifier: pid_t? + let activationPolicy: String? + let permitted: Bool + let windows: [DiagnosticWindow] + let accessibilityWindows: [DiagnosticAccessibilityWindow] +} +``` + +- [ ] **Step 4: Run the focused tests and verify they pass** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: all evaluator tests pass. + +- [ ] **Step 5: Commit the evaluator slice** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: model browser diagnostic outcomes" +``` + +### Task 2: Add privacy redaction and deterministic report rendering + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing redaction and rendering tests** + +Add tests that exercise the exact privacy boundary: + +```swift +func testRedactorRemovesVerificationCodesAndExtensionIdentifiers() { + let input = "Arc 123456 chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" + let output = DiagnosticRedactor.redactText(input) + XCTAssertFalse(output.contains("123456")) + XCTAssertFalse(output.contains("abcdefghijklmnop")) + XCTAssertTrue(output.contains("")) + XCTAssertTrue(output.contains("chrome-extension:///page_popup.html")) +} + +func testRedactorRemovesSeparatedVerificationCode() { + XCTAssertEqual( + DiagnosticRedactor.redactText("验证码 1 2 3 4 5 6"), + "验证码 " + ) +} + +func testPlainTextReportContainsLabeledSystemFieldsWithoutRawAXText() { + let report = BrowserDiagnosticReport( + generatedAt: Date(timeIntervalSince1970: 0), + ruleMode: .allowlist, + applications: [recognizedApplication(roleCounts: ["AXTextField": 6])] + ) + XCTAssertTrue(report.plainText.contains("company.thebrowser.Browser")) + XCTAssertTrue(report.plainText.contains("PID: 42")) + XCTAssertTrue(report.plainText.contains("target_recognized")) + XCTAssertFalse(report.plainText.contains("123456")) +} +``` + +- [ ] **Step 2: Run tests and verify redaction APIs are missing** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: compilation fails for `DiagnosticRedactor` and `BrowserDiagnosticReport`. + +- [ ] **Step 3: Implement redaction and report rendering** + +Add `DiagnosticRedactor` with precompiled `NSRegularExpression` instances. Apply the six-digit expression before generic numeric redaction, redact extension hosts, strip query strings, and cap redacted titles at 160 characters: + +```swift +enum DiagnosticRedactor { + private static let sixDigits = try! NSRegularExpression( + pattern: #"(? String { + var output = replace( + sixDigits, + in: value, + with: "" + ) + output = redactExtensionURLs(in: output) + output = replace(otherNumbers, in: output, with: "") + return output + } + + static func redactTitle(_ value: String?) -> String { + let redacted = redactText(value ?? "") + return String(redacted.prefix(160)) + } + + static func extensionURLSummary(from value: String) -> String? { + let range = NSRange(value.startIndex..., in: value) + guard let match = extensionURL.firstMatch(in: value, range: range), + let swiftRange = Range(match.range, in: value) else { return nil } + return summarizeExtensionURL(String(value[swiftRange])) + } + + private static func redactExtensionURLs(in value: String) -> String { + let range = NSRange(value.startIndex..., in: value) + let matches = extensionURL.matches(in: value, range: range).reversed() + var output = value + for match in matches { + guard let swiftRange = Range(match.range, in: output) else { continue } + output.replaceSubrange(swiftRange, with: summarizeExtensionURL(String(output[swiftRange]))) + } + return output + } + + private static func summarizeExtensionURL(_ value: String) -> String { + let withoutQuery = value.split(separator: "?", maxSplits: 1).first.map(String.init) ?? value + guard let schemeRange = withoutQuery.range(of: "://"), + let slash = withoutQuery[schemeRange.upperBound...].firstIndex(of: "/") else { + return "" + } + return String(withoutQuery[.." + + String(withoutQuery[slash...]) + } + + private static func replace( + _ expression: NSRegularExpression, + in value: String, + with replacement: String + ) -> String { + expression.stringByReplacingMatches( + in: value, + range: NSRange(value.startIndex..., in: value), + withTemplate: replacement + ) + } +} + +struct BrowserDiagnosticReport: Equatable { + let generatedAt: Date + let ruleMode: ApplicationRuleMode + let applications: [DiagnosticApplication] + + var conclusion: BrowserDiagnosticConclusion { + .evaluate(applications) + } + + var summary: String { conclusion.localizedSummary } + + var plainText: String { + var lines = [ + "ApplePasswordBridge browser diagnostics", + "Generated: \(generatedAt.ISO8601Format())", + "Rule mode: \(ruleMode.rawValue)", + "Conclusion: \(conclusion.rawValue) — \(summary)" + ] + for application in applications { + lines.append("Application: \(application.displayName) [\(application.bundleIdentifier)]") + lines.append(" PID: \(application.processIdentifier.map(String.init) ?? "not-running")") + lines.append(" Activation policy: \(application.activationPolicy ?? "unavailable")") + lines.append(" Permitted: \(application.permitted)") + for window in application.windows { + lines.append(" CG window #\(window.number): \(window.width)x\(window.height)") + lines.append(" Title: \(window.title)") + lines.append(" iCloud title match: \(window.titleMatches)") + } + for window in application.accessibilityWindows { + lines.append(" AX window: \(window.title)") + lines.append(" Extension URL: \(window.extensionURLSummary ?? "not-observed")") + lines.append(" Flags: extension=\(window.hasExtensionURL), popup=\(window.hasPopupPath), icloud=\(window.hasICloudIdentity), autofill=\(window.hasAutofillTerms), code=\(window.hasVerificationCodeTerms)") + let roles = window.roleCounts.keys.sorted().map { "\($0)=\(window.roleCounts[$0] ?? 0)" } + lines.append(" Roles: \(roles.joined(separator: ", "))") + lines.append(" Existing match: popup=\(window.stablePopupSignature), authorization=\(window.authorizationContextMatches)") + } + } + return lines.joined(separator: "\n") + } +} + +protocol BrowserDiagnosticsRunning { + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date + ) -> BrowserDiagnosticReport +} +``` + +Add `localizedSummary` to `BrowserDiagnosticConclusion` with an exhaustive `switch` mapping each case to the Chinese labels in the approved design. Do not store or render raw AX text anywhere in these types. + +- [ ] **Step 4: Run focused tests and inspect privacy assertions** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: tests pass; the test output contains no supplied code or extension identifier. + +- [ ] **Step 5: Commit the privacy slice** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: redact and render diagnostic reports" +``` + +### Task 3: Implement the read-only live macOS collector + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing pure-observation tests for Arc and Chromium metadata** + +Add tests for the collector's pure helpers rather than mocking macOS frameworks: + +```swift +func testArcAllowlistEntryIsIncluded() { + let policy = ApplicationRulePolicy( + mode: .allowlist, + allowlist: ["company.thebrowser.Browser"], + denylist: [] + ) + XCTAssertTrue(policy.permits(bundleIdentifier: "company.thebrowser.Browser")) +} + +func testAXObservationRecognizesChromiumPopupWithoutKeepingRawText() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: "iCloud Passwords", + nodes: [ + DiagnosticNode(role: "AXStaticText", text: "chrome-extension://secret-id/page_popup.html"), + DiagnosticNode(role: "AXTextField", text: "") + ] + ) + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) + XCTAssertEqual(observation.roleCounts["AXTextField"], 1) + XCTAssertFalse(String(describing: observation).contains("secret-id")) +} +``` + +The resulting observation must retain flags and redacted summaries, never the supplied raw combined text. + +- [ ] **Step 2: Run focused tests and verify the helper is missing** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: compilation fails for `DiagnosticNode` or `makeAccessibilityObservation`. + +- [ ] **Step 3: Implement `BrowserDiagnostics` collection** + +First expose exact semantic helpers in `CodeParser.swift` and route the existing authorization check through them: + +```swift +static func hasAutofillTerms(_ text: String) -> Bool { + let value = text.lowercased() + return autofillTerms.contains(where: value.contains) +} + +static func hasVerificationCodeTerms(_ text: String) -> Bool { + let value = text.lowercased() + return codeTerms.contains(where: value.contains) +} + +static func isApplePasswordAuthorization(_ text: String) -> Bool { + hasAutofillTerms(text) && hasVerificationCodeTerms(text) +} +``` + +Then add the collector implementation: + +```swift +struct DiagnosticNode { + let role: String + let text: String +} + +final class BrowserDiagnostics: BrowserDiagnosticsRunning { + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date = Date() + ) -> BrowserDiagnosticReport { + let runningApplications = applicationsToInspect( + policy: policy, + configuredApplications: configuredApplications + ) + let windowInfo = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] ?? [] + + let observations = runningApplications.map { configured, running in + let pid = running?.processIdentifier + let windows = pid.map { processIdentifier in + windowInfo.compactMap { info -> DiagnosticWindow? in + guard (info[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == processIdentifier, + let number = info[kCGWindowNumber as String] as? NSNumber else { return nil } + let rawBounds = info[kCGWindowBounds as String] as? [String: Any] + let bounds = rawBounds.flatMap { + CGRect(dictionaryRepresentation: $0 as CFDictionary) + } ?? .zero + let title = DiagnosticRedactor.redactTitle(info[kCGWindowName as String] as? String) + return DiagnosticWindow( + number: number.uint32Value, + width: Int(bounds.width), + height: Int(bounds.height), + title: title, + titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(title) + ) + } + } ?? [] + + let accessibilityWindows: [DiagnosticAccessibilityWindow] + if let running { + let application = AXUIElementCreateApplication(running.processIdentifier) + AccessibilityTree.enableEnhancedUserInterface(application) + accessibilityWindows = AccessibilityTree.windows(of: application).map { window in + let nodes = AccessibilityTree.collect(from: window).map { + DiagnosticNode(role: $0.role, text: $0.text) + } + return Self.makeAccessibilityObservation( + title: AccessibilityTree.title(of: window), + nodes: nodes + ) + } + } else { + accessibilityWindows = [] + } + + return DiagnosticApplication( + displayName: configured.displayName, + bundleIdentifier: configured.bundleIdentifier, + processIdentifier: pid, + activationPolicy: running.map { activationPolicyName($0.activationPolicy) }, + permitted: policy.permits(bundleIdentifier: configured.bundleIdentifier), + windows: windows, + accessibilityWindows: accessibilityWindows + ) + } + + return BrowserDiagnosticReport( + generatedAt: now, + ruleMode: policy.mode, + applications: observations + ) + } + + static func makeAccessibilityObservation( + title: String?, + nodes: [DiagnosticNode] + ) -> DiagnosticAccessibilityWindow { + let combined = nodes.map(\.text).joined(separator: "\n") + let lowercased = combined.lowercased() + let roleCounts = Dictionary(grouping: nodes, by: \.role).mapValues(\.count) + return DiagnosticAccessibilityWindow( + title: DiagnosticRedactor.redactTitle(title), + extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), + hasExtensionURL: lowercased.contains("moz-extension://") + || lowercased.contains("chrome-extension://"), + hasPopupPath: lowercased.contains("/page_popup.html"), + hasICloudIdentity: lowercased.contains("icloud 密码") + || lowercased.contains("icloud passwords") + || AuthorizationContext.isICloudPasswordWindowTitle(title), + hasAutofillTerms: AuthorizationContext.hasAutofillTerms(combined), + hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(combined), + roleCounts: roleCounts, + stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), + authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined) + ) + } + + private func applicationsToInspect( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication] + ) -> [(TargetApplication, NSRunningApplication?)] { + switch policy.mode { + case .allowlist: + return configuredApplications.map { configured in + let running = NSRunningApplication.runningApplications( + withBundleIdentifier: configured.bundleIdentifier + ).first + return (configured, running) + } + case .denylist: + return NSWorkspace.shared.runningApplications + .filter { + $0.activationPolicy == .regular + && $0.bundleIdentifier.map(policy.permits(bundleIdentifier:)) == true + } + .prefix(12) + .compactMap { running in + guard let bundleIdentifier = running.bundleIdentifier else { return nil } + let configured = TargetApplication( + bundleIdentifier: bundleIdentifier, + displayName: running.localizedName ?? bundleIdentifier + ) + return (configured, running) + } + } + } + + private func activationPolicyName(_ policy: NSApplication.ActivationPolicy) -> String { + switch policy { + case .regular: return "regular" + case .accessory: return "accessory" + case .prohibited: return "prohibited" + @unknown default: return "unknown" + } + } +} +``` + +Review the implementation mechanically to confirm it never calls `AccessibilityTree.focus`, `raise`, `NSRunningApplication.activate`, `PasswordCodeReader`, or any `CGEvent` API. + +- [ ] **Step 4: Run the full unit suite** + +Run `swift test --disable-sandbox`. + +Expected: all existing and diagnostic tests pass; no behavior changes to autofill tests. + +- [ ] **Step 5: Commit the collector slice** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Sources/ApplePasswordBridge/CodeParser.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: collect browser recognition diagnostics" +``` + +Only stage `CodeParser.swift` if shared internal semantic helpers were required. + +### Task 4: Integrate diagnostic state and copy action into `BridgeModel` + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeModel.swift:23-52,116-161,335-341` +- Modify: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Add a failing state-transition test around an injected runner** + +Reuse the protocol added with report rendering: + +```swift +protocol BrowserDiagnosticsRunning { + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date + ) -> BrowserDiagnosticReport +} +``` + +Add a fake runner test proving a diagnostic request stores the report and resets `isDiagnosing`: + +```swift +private final class FakeDiagnosticsRunner: BrowserDiagnosticsRunning { + let report: BrowserDiagnosticReport + + init(report: BrowserDiagnosticReport) { + self.report = report + } + + func run( + policy: ApplicationRulePolicy, + configuredApplications: [TargetApplication], + now: Date + ) -> BrowserDiagnosticReport { + report + } +} + +@MainActor +func testModelStoresCompletedDiagnosticReport() { + let report = BrowserDiagnosticReport( + generatedAt: Date(timeIntervalSince1970: 0), + ruleMode: .allowlist, + applications: [recognizedApplication(roleCounts: ["AXTextField": 6])] + ) + let model = BridgeModel( + browserDiagnostics: FakeDiagnosticsRunner(report: report), + startAutomatically: false + ) + XCTAssertNil(model.diagnosticReport) + model.runDiagnostics() + XCTAssertEqual(model.diagnosticReport, report) + XCTAssertFalse(model.isDiagnosing) +} +``` + +- [ ] **Step 2: Run the focused test and verify initializer/state APIs are missing** + +Run `swift test --disable-sandbox --filter BrowserDiagnosticsTests`. + +Expected: compilation fails for the injected initializer and diagnostic properties. + +- [ ] **Step 3: Add model state and actions** + +Add these members: + +```swift +@Published private(set) var diagnosticReport: BrowserDiagnosticReport? +@Published private(set) var isDiagnosing = false + +private let browserDiagnostics: BrowserDiagnosticsRunning + +init( + browserDiagnostics: BrowserDiagnosticsRunning = BrowserDiagnostics(), + startAutomatically: Bool = true +) { + self.browserDiagnostics = browserDiagnostics + UserDefaults.standard.register(defaults: [ + Keys.monitoring: true, + Keys.automaticFill: true, + Keys.ocrFallback: true, + Keys.fillSpeed: FillSpeed.reliable.rawValue, + Keys.applicationRuleMode: ApplicationRuleMode.allowlist.rawValue + ]) + monitoringEnabled = UserDefaults.standard.bool(forKey: Keys.monitoring) + automaticFillEnabled = UserDefaults.standard.bool(forKey: Keys.automaticFill) + ocrFallbackEnabled = UserDefaults.standard.bool(forKey: Keys.ocrFallback) + fillSpeed = FillSpeed( + rawValue: UserDefaults.standard.string(forKey: Keys.fillSpeed) ?? "" + ) ?? .reliable + applicationRuleMode = ApplicationRuleMode( + rawValue: UserDefaults.standard.string(forKey: Keys.applicationRuleMode) ?? "" + ) ?? .allowlist + allowlistedApplications = Self.loadApplications( + key: Keys.allowlistedApplications, + fallback: [.firefox] + ) + denylistedApplications = Self.loadApplications( + key: Keys.denylistedApplications, + fallback: [] + ) + if startAutomatically { + Task { @MainActor [weak self] in self?.start() } + } +} + +func runDiagnostics() { + guard !isDiagnosing else { return } + isDiagnosing = true + defer { isDiagnosing = false } + diagnosticReport = browserDiagnostics.run( + policy: applicationPolicy, + configuredApplications: activeApplicationRules, + now: Date() + ) +} + +func copyDiagnosticReport() { + guard let report = diagnosticReport else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report.plainText, forType: .string) +} +``` + +Keep diagnostic state independent of `isWorking` and `statusText`, so running diagnostics does not impersonate or interrupt autofill. + +- [ ] **Step 4: Run focused and full tests** + +Run: + +```bash +swift test --disable-sandbox --filter BrowserDiagnosticsTests +swift test --disable-sandbox +``` + +Expected: all tests pass, and test construction does not prompt for macOS permissions. + +- [ ] **Step 5: Commit model integration** + +```bash +git add Sources/ApplePasswordBridge/BridgeModel.swift Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: expose one-shot browser diagnostics" +``` + +### Task 5: Add the menu diagnostics interface + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BridgeApp.swift:59-67,123-140` + +- [ ] **Step 1: Build before the UI change to establish the baseline** + +Run `swift build --disable-sandbox`. + +Expected: build succeeds before modifying `BridgeApp.swift`. + +- [ ] **Step 2: Add the diagnostic controls** + +Insert a compact section after application rules and before permissions: + +```swift +VStack(alignment: .leading, spacing: 8) { + Button(action: model.runDiagnostics) { + Label( + model.isDiagnosing ? "正在诊断…" : "诊断当前授权窗口", + systemImage: "stethoscope" + ) + .frame(maxWidth: .infinity) + } + .disabled(model.isDiagnosing) + + if let report = model.diagnosticReport { + Text(report.summary) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + HStack { + Text(report.generatedAt, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Button("复制诊断报告", action: model.copyDiagnosticReport) + .controlSize(.small) + } + } +} +``` + +Keep the menu width at 340 points unless the localized summary clips in a release build; allow two summary lines instead of widening the menu. + +- [ ] **Step 3: Build and run unit tests** + +Run: + +```bash +swift build --disable-sandbox +swift test --disable-sandbox +``` + +Expected: build and all tests pass. + +- [ ] **Step 4: Commit the UI slice** + +```bash +git add Sources/ApplePasswordBridge/BridgeApp.swift +git commit -m "feat: show browser diagnostics in menu" +``` + +### Task 6: Package and manually validate the Arc diagnostic build + +**Files:** +- Modify only if validation reveals a diagnostic-only defect: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift`, `BridgeModel.swift`, `BridgeApp.swift`, or `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Run clean verification** + +Run: + +```bash +swift test --disable-sandbox +swift build -c release --disable-sandbox +``` + +Expected: all tests pass and the release executable builds. + +- [ ] **Step 2: Build the app bundle** + +Run `make app`. + +Expected: `dist/Password Bridge.app` exists and `codesign --verify --deep --strict "dist/Password Bridge.app"` succeeds with the project's ad-hoc signature. + +- [ ] **Step 3: Manually collect one Arc report** + +1. Launch the diagnostic app build. +2. Confirm Accessibility permission is granted to this exact rebuilt app identity. +3. Keep Arc's iCloud Passwords authorization UI visible. +4. Click “诊断当前授权窗口”. +5. Confirm the UI names exactly one earliest failing boundary. +6. Click “复制诊断报告”. +7. Inspect the pasted text: it must contain Arc's bundle ID, PID, window/AX counts, title-match flags, extension flags, and role counts; it must not contain a six-digit code, extension identifier, raw AX body text, or Apple Passwords content. + +- [ ] **Step 4: Recheck normal behavior** + +With no diagnostic action running, confirm “立即填入”, the global hotkey, the 0.5-second automatic scan, application rules, and permission rows behave exactly as before. + +- [ ] **Step 5: Commit only validation-driven diagnostic corrections** + +If validation required a correction, first add a failing regression test, implement the minimal change, rerun the full suite, then commit: + +```bash +git add Sources/ApplePasswordBridge Tests/ApplePasswordBridgeTests +git commit -m "fix: harden Arc diagnostic reporting" +``` + +If no correction was required, do not create an empty commit. + +## Final acceptance checklist + +- [ ] `swift test --disable-sandbox` passes. +- [ ] `swift build -c release --disable-sandbox` passes. +- [ ] `make app` produces a valid ad-hoc signed application bundle. +- [ ] Diagnostic execution never calls code reading, focus, activation, or keyboard-event APIs. +- [ ] Copied reports contain no verification codes, extension identifiers, query strings, or raw AX text. +- [ ] An Arc run identifies one concrete failing boundary instead of the shared `authorizationWindowNotFound` message. +- [ ] Existing autofill matching logic and behavior remain unchanged. From 65dc16dfc157ae749c2b6b87f878d502a090da7b Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:44:45 +0800 Subject: [PATCH 03/23] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4c739aa..83dc698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store .build/ +.worktrees/ dist/ *.xcuserstate From d65a65ccf116a2ad67d2fea0a85fb806def2e48a Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:48:11 +0800 Subject: [PATCH 04/23] feat: model browser diagnostic outcomes --- .../BrowserDiagnostics.swift | 68 +++++++++++++++++++ .../BrowserDiagnosticsTests.swift | 29 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 Sources/ApplePasswordBridge/BrowserDiagnostics.swift create mode 100644 Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift new file mode 100644 index 0000000..11d9f92 --- /dev/null +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -0,0 +1,68 @@ +import Foundation + +public enum BrowserDiagnosticConclusion: String, Equatable { + case applicationNotRunning = "application_not_running" + case applicationRejectedByPolicy = "application_rejected_by_policy" + case noVisibleWindows = "no_visible_windows" + case windowTitleMismatch = "window_title_mismatch" + case accessibilityWindowsUnavailable = "ax_windows_unavailable" + case authorizationContextMismatch = "authorization_context_mismatch" + case inputRolesUnrecognized = "input_roles_unrecognized" + case targetRecognized = "target_recognized" +} + +public struct DiagnosticWindow: Equatable { + public let number: UInt32 + public let width: Int + public let height: Int + public let title: String + public let titleMatches: Bool + public init(number: UInt32, width: Int, height: Int, title: String, titleMatches: Bool) { + self.number = number; self.width = width; self.height = height; self.title = title; self.titleMatches = titleMatches + } +} + +public struct DiagnosticAccessibilityWindow: Equatable { + public let title: String + public let extensionURLSummary: String? + public let hasExtensionURL: Bool + public let hasPopupPath: Bool + public let hasICloudIdentity: Bool + public let hasAutofillTerms: Bool + public let hasVerificationCodeTerms: Bool + public let roleCounts: [String: Int] + public let stablePopupSignature: Bool + public let authorizationContextMatches: Bool + public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { + self.title = title; self.extensionURLSummary = extensionURLSummary; self.hasExtensionURL = hasExtensionURL; self.hasPopupPath = hasPopupPath; self.hasICloudIdentity = hasICloudIdentity; self.hasAutofillTerms = hasAutofillTerms; self.hasVerificationCodeTerms = hasVerificationCodeTerms; self.roleCounts = roleCounts; self.stablePopupSignature = stablePopupSignature; self.authorizationContextMatches = authorizationContextMatches + } + public var supportedInputCount: Int { ["AXTextField", "AXTextArea", "AXSecureTextField"].reduce(0) { $0 + (roleCounts[$1] ?? 0) } } +} + +public struct DiagnosticApplication: Equatable { + public let displayName: String + public let bundleIdentifier: String + public let processIdentifier: pid_t? + public let activationPolicy: String? + public let permitted: Bool + public let windows: [DiagnosticWindow] + public let accessibilityWindows: [DiagnosticAccessibilityWindow] + public init(displayName: String, bundleIdentifier: String, processIdentifier: pid_t?, activationPolicy: String?, permitted: Bool, windows: [DiagnosticWindow], accessibilityWindows: [DiagnosticAccessibilityWindow]) { + self.displayName = displayName; self.bundleIdentifier = bundleIdentifier; self.processIdentifier = processIdentifier; self.activationPolicy = activationPolicy; self.permitted = permitted; self.windows = windows; self.accessibilityWindows = accessibilityWindows + } +} + +public enum BrowserDiagnostics { + public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { + guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } + let running = applications.filter { $0.processIdentifier != nil } + guard running.contains(where: { $0.permitted }) else { return .applicationRejectedByPolicy } + let permitted = running.filter { $0.permitted } + guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 } }) else { return .noVisibleWindows } + guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 && $0.titleMatches } }) else { return .windowTitleMismatch } + guard permitted.contains(where: { !$0.accessibilityWindows.isEmpty }) else { return .accessibilityWindowsUnavailable } + guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches } }) else { return .authorizationContextMismatch } + guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches && $0.supportedInputCount > 0 } }) else { return .inputRolesUnrecognized } + return .targetRecognized + } +} diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift new file mode 100644 index 0000000..fd6fd4e --- /dev/null +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import ApplePasswordBridge + +final class BrowserDiagnosticsTests: XCTestCase { + private func application(running: Bool = true, permitted: Bool = true, windows: [DiagnosticWindow] = [], accessibilityWindows: [DiagnosticAccessibilityWindow] = []) -> DiagnosticApplication { + DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: running ? 42 : nil, activationPolicy: "regular", permitted: permitted, windows: windows, accessibilityWindows: accessibilityWindows) + } + + private let visible = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Arc", titleMatches: true) + private let target = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) + + func test_conclusions_follow_earliest_failure_boundary() { + XCTAssertEqual(BrowserDiagnostics.evaluate([application(running: false)]), .applicationNotRunning) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(permitted: false)]), .applicationRejectedByPolicy) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [])]), .noVisibleWindows) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [DiagnosticWindow(number: 1, width: 1, height: 1, title: "Other", titleMatches: false)])]), .windowTitleMismatch) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible])]), .accessibilityWindowsUnavailable) + let noAuth = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [noAuth])]), .authorizationContextMismatch) + let noInput = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2], stablePopupSignature: true, authorizationContextMatches: true) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [noInput])]), .inputRolesUnrecognized) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [target])]), .targetRecognized) + } + + func test_supported_input_count_only_counts_text_roles() { + let window = DiagnosticAccessibilityWindow(title: "", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: ["AXTextField": 2, "AXTextArea": 3, "AXSecureTextField": 4, "AXButton": 99], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertEqual(window.supportedInputCount, 9) + } +} From 6a3d96f29ee07a61893d3624e7806f4b4a0a668f Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:50:57 +0800 Subject: [PATCH 05/23] fix: align diagnostic outcome evaluation --- Sources/ApplePasswordBridge/BrowserDiagnostics.swift | 9 +++++---- .../BrowserDiagnosticsTests.swift | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 11d9f92..3800831 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -58,11 +58,12 @@ public enum BrowserDiagnostics { let running = applications.filter { $0.processIdentifier != nil } guard running.contains(where: { $0.permitted }) else { return .applicationRejectedByPolicy } let permitted = running.filter { $0.permitted } - guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 } }) else { return .noVisibleWindows } - guard permitted.contains(where: { $0.windows.contains { $0.width > 0 && $0.height > 0 && $0.titleMatches } }) else { return .windowTitleMismatch } + guard permitted.contains(where: { !$0.windows.isEmpty }) else { return .noVisibleWindows } + guard permitted.contains(where: { $0.windows.contains { $0.titleMatches } }) else { return .windowTitleMismatch } guard permitted.contains(where: { !$0.accessibilityWindows.isEmpty }) else { return .accessibilityWindowsUnavailable } - guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches } }) else { return .authorizationContextMismatch } - guard permitted.contains(where: { $0.accessibilityWindows.contains { $0.stablePopupSignature && $0.authorizationContextMatches && $0.supportedInputCount > 0 } }) else { return .inputRolesUnrecognized } + let matching = permitted.flatMap { $0.accessibilityWindows }.filter { $0.stablePopupSignature || $0.authorizationContextMatches } + guard !matching.isEmpty else { return .authorizationContextMismatch } + guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } return .targetRecognized } } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index fd6fd4e..cd89382 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -20,6 +20,15 @@ final class BrowserDiagnosticsTests: XCTestCase { let noInput = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2], stablePopupSignature: true, authorizationContextMatches: true) XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [noInput])]), .inputRolesUnrecognized) XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [target])]), .targetRecognized) + + let stableOnly = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: false) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [stableOnly])]), .targetRecognized) + + let contextOnly = DiagnosticAccessibilityWindow(title: "Arc", extensionURLSummary: "ext", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 1], stablePopupSignature: false, authorizationContextMatches: true) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [contextOnly])]), .targetRecognized) + + let zeroSized = DiagnosticWindow(number: 1, width: 0, height: 0, title: "Other", titleMatches: false) + XCTAssertEqual(BrowserDiagnostics.evaluate([application(windows: [zeroSized])]), .windowTitleMismatch) } func test_supported_input_count_only_counts_text_roles() { From a7aebe5cb3f57d3bc607d5646b13e93e45b5f17f Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:03:06 +0800 Subject: [PATCH 06/23] test: cover diagnostic outcome precedence --- .../BrowserDiagnostics.swift | 25 ++++++++++++++-- .../BrowserDiagnosticsTests.swift | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 3800831..09b4df4 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -18,7 +18,11 @@ public struct DiagnosticWindow: Equatable { public let title: String public let titleMatches: Bool public init(number: UInt32, width: Int, height: Int, title: String, titleMatches: Bool) { - self.number = number; self.width = width; self.height = height; self.title = title; self.titleMatches = titleMatches + self.number = number + self.width = width + self.height = height + self.title = title + self.titleMatches = titleMatches } } @@ -34,7 +38,16 @@ public struct DiagnosticAccessibilityWindow: Equatable { public let stablePopupSignature: Bool public let authorizationContextMatches: Bool public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { - self.title = title; self.extensionURLSummary = extensionURLSummary; self.hasExtensionURL = hasExtensionURL; self.hasPopupPath = hasPopupPath; self.hasICloudIdentity = hasICloudIdentity; self.hasAutofillTerms = hasAutofillTerms; self.hasVerificationCodeTerms = hasVerificationCodeTerms; self.roleCounts = roleCounts; self.stablePopupSignature = stablePopupSignature; self.authorizationContextMatches = authorizationContextMatches + self.title = title + self.extensionURLSummary = extensionURLSummary + self.hasExtensionURL = hasExtensionURL + self.hasPopupPath = hasPopupPath + self.hasICloudIdentity = hasICloudIdentity + self.hasAutofillTerms = hasAutofillTerms + self.hasVerificationCodeTerms = hasVerificationCodeTerms + self.roleCounts = roleCounts + self.stablePopupSignature = stablePopupSignature + self.authorizationContextMatches = authorizationContextMatches } public var supportedInputCount: Int { ["AXTextField", "AXTextArea", "AXSecureTextField"].reduce(0) { $0 + (roleCounts[$1] ?? 0) } } } @@ -48,7 +61,13 @@ public struct DiagnosticApplication: Equatable { public let windows: [DiagnosticWindow] public let accessibilityWindows: [DiagnosticAccessibilityWindow] public init(displayName: String, bundleIdentifier: String, processIdentifier: pid_t?, activationPolicy: String?, permitted: Bool, windows: [DiagnosticWindow], accessibilityWindows: [DiagnosticAccessibilityWindow]) { - self.displayName = displayName; self.bundleIdentifier = bundleIdentifier; self.processIdentifier = processIdentifier; self.activationPolicy = activationPolicy; self.permitted = permitted; self.windows = windows; self.accessibilityWindows = accessibilityWindows + self.displayName = displayName + self.bundleIdentifier = bundleIdentifier + self.processIdentifier = processIdentifier + self.activationPolicy = activationPolicy + self.permitted = permitted + self.windows = windows + self.accessibilityWindows = accessibilityWindows } } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index cd89382..d96c3bf 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -35,4 +35,33 @@ final class BrowserDiagnosticsTests: XCTestCase { let window = DiagnosticAccessibilityWindow(title: "", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: ["AXTextField": 2, "AXTextArea": 3, "AXSecureTextField": 4, "AXButton": 99], stablePopupSignature: false, authorizationContextMatches: false) XCTAssertEqual(window.supportedInputCount, 9) } + + func test_running_unpermitted_match_is_rejected_before_window_and_accessibility_checks() { + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(permitted: false, windows: [visible], accessibilityWindows: [target])]), + .applicationRejectedByPolicy + ) + } + + func test_permitted_window_title_mismatch_precedes_matching_accessibility_input() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [target])]), + .windowTitleMismatch + ) + } + + func test_matching_accessibility_window_is_not_shadowed_by_first_mismatch() { + let mismatch = DiagnosticAccessibilityWindow(title: "Other", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [visible], accessibilityWindows: [mismatch, target])]), + .targetRecognized + ) + } + + func test_permitted_running_application_succeeds_among_other_application_states() { + let other = application(running: false, permitted: false, windows: [visible], accessibilityWindows: [target]) + let permitted = application(windows: [visible], accessibilityWindows: [target]) + XCTAssertEqual(BrowserDiagnostics.evaluate([other, permitted]), .targetRecognized) + } } From 3a5392f20fb21b6c18a94828a32b8545bd068c00 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:06:36 +0800 Subject: [PATCH 07/23] feat: redact and render diagnostic reports --- .../BrowserDiagnostics.swift | 53 +++++++++++++++++++ .../BrowserDiagnosticsTests.swift | 20 +++++++ 2 files changed, 73 insertions(+) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 09b4df4..e4b7d18 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -9,6 +9,34 @@ public enum BrowserDiagnosticConclusion: String, Equatable { case authorizationContextMismatch = "authorization_context_mismatch" case inputRolesUnrecognized = "input_roles_unrecognized" case targetRecognized = "target_recognized" + public var localizedSummary: String { switch self { + case .applicationNotRunning: return "目标应用未运行" + case .applicationRejectedByPolicy: return "应用被规则拒绝" + case .noVisibleWindows: return "没有可见窗口" + case .windowTitleMismatch: return "窗口标题不匹配" + case .accessibilityWindowsUnavailable: return "无法获取辅助功能窗口" + case .authorizationContextMismatch: return "授权上下文不匹配" + case .inputRolesUnrecognized: return "未识别到输入控件" + case .targetRecognized: return "已识别目标弹窗" } + } +} + +public enum DiagnosticRedactor { + private static let code = try! NSRegularExpression(pattern: #"(? String { + var s = text + s = ext.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1://$2") + s = code.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") + s = number.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") + return s + } + public static func extensionURLSummary(from text: String) -> String? { + guard let m = ext.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let r = Range(m.range, in: text) else { return nil } + return redactText(String(text[r])) + } + public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } } public struct DiagnosticWindow: Equatable { @@ -86,3 +114,28 @@ public enum BrowserDiagnostics { return .targetRecognized } } + +struct BrowserDiagnosticReport: Equatable { + public let generatedAt: Date + public let ruleMode: ApplicationRuleMode + public let applications: [DiagnosticApplication] + init(generatedAt: Date, ruleMode: ApplicationRuleMode, applications: [DiagnosticApplication]) { self.generatedAt = generatedAt; self.ruleMode = ruleMode; self.applications = applications } + public var conclusion: BrowserDiagnosticConclusion { BrowserDiagnostics.evaluate(applications) } + public var summary: String { conclusion.localizedSummary } + func plainText(policy: ApplicationRulePolicy) -> String { + var lines = ["generatedAt=\(generatedAt.timeIntervalSince1970)", "ruleMode=\(ruleMode.rawValue)", "conclusion=\(conclusion.rawValue)", "summary=\(summary)"] + for app in applications { + let pid = app.processIdentifier.map(String.init) ?? "nil" + lines.append("app=\(app.displayName) bundle=\(app.bundleIdentifier) pid=\(pid) activation=\(app.activationPolicy ?? "nil") permitted=\(app.permitted)") + for w in app.windows { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } + for ax in app.accessibilityWindows { + let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary.map(DiagnosticRedactor.redactText) ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + } + } + return lines.joined(separator: "\n") + } + public var plainText: String { plainText(policy: ApplicationRulePolicy(mode: ruleMode, allowlist: [], denylist: [])) } +} + +protocol BrowserDiagnosticsRunning { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index d96c3bf..1f15d14 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -64,4 +64,24 @@ final class BrowserDiagnosticsTests: XCTestCase { let permitted = application(windows: [visible], accessibilityWindows: [target]) XCTAssertEqual(BrowserDiagnostics.evaluate([other, permitted]), .targetRecognized) } + func test_redactor_masks_codes_numbers_extensions_and_titles() { + XCTAssertEqual(DiagnosticRedactor.redactText("code 123456 and 1 2 3 4 5 6 and 1-2-3-4-5-6"), "code and and ") + let ext = "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" + XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: ext), "chrome-extension:///page_popup.html") + XCTAssertEqual(DiagnosticRedactor.redactTitle(nil), "") + XCTAssertLessThanOrEqual(DiagnosticRedactor.redactTitle(String(repeating: "a", count: 200)).count, 160) + } + + func test_report_plain_text_is_deterministic_and_redacted() { + let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) + let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) + let policy = ApplicationRulePolicy(mode: .allowlist, allowlist: [app.bundleIdentifier], denylist: []) + let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) + let text = report.plainText(policy: policy) + XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) + } + + func test_all_conclusions_have_chinese_summary() { + for c in [BrowserDiagnosticConclusion.applicationNotRunning,.applicationRejectedByPolicy,.noVisibleWindows,.windowTitleMismatch,.accessibilityWindowsUnavailable,.authorizationContextMismatch,.inputRolesUnrecognized,.targetRecognized] { XCTAssertFalse(c.localizedSummary.isEmpty) } + } } From 33f0b6864cb8e67ca9a198d3369dedc5136eaaf3 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:10:24 +0800 Subject: [PATCH 08/23] fix: redact diagnostic observations at capture --- .../ApplePasswordBridge/BrowserDiagnostics.swift | 6 +++--- .../BrowserDiagnosticsTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index e4b7d18..1a7e6d6 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -49,7 +49,7 @@ public struct DiagnosticWindow: Equatable { self.number = number self.width = width self.height = height - self.title = title + self.title = DiagnosticRedactor.redactTitle(title) self.titleMatches = titleMatches } } @@ -66,8 +66,8 @@ public struct DiagnosticAccessibilityWindow: Equatable { public let stablePopupSignature: Bool public let authorizationContextMatches: Bool public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { - self.title = title - self.extensionURLSummary = extensionURLSummary + self.title = DiagnosticRedactor.redactTitle(title) + self.extensionURLSummary = extensionURLSummary.flatMap { DiagnosticRedactor.extensionURLSummary(from: $0) } self.hasExtensionURL = hasExtensionURL self.hasPopupPath = hasPopupPath self.hasICloudIdentity = hasICloudIdentity diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 1f15d14..9b237cf 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -72,6 +72,20 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertLessThanOrEqual(DiagnosticRedactor.redactTitle(String(repeating: "a", count: 200)).count, 160) } + func test_observation_initializers_redact_sensitive_fields_at_capture() { + let window = DiagnosticWindow(number: 1, width: 1, height: 1, title: "code 123456", titleMatches: false) + XCTAssertFalse(window.title.contains("123456")) + XCTAssertTrue(window.title.contains("")) + + let ext = "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" + let ax = DiagnosticAccessibilityWindow(title: "code 123456", extensionURLSummary: ext, hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) + XCTAssertFalse(ax.title.contains("123456")) + XCTAssertTrue(ax.title.contains("")) + XCTAssertEqual(ax.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertFalse(ax.extensionURLSummary?.contains("abcdefghijklmnop") ?? false) + XCTAssertFalse(ax.extensionURLSummary?.contains("popupWindow=42") ?? false) + } + func test_report_plain_text_is_deterministic_and_redacted() { let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) From f62bd5df7ba0a8c3b8ad5b4a856453efd8c8d008 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:15:32 +0800 Subject: [PATCH 09/23] fix: preserve diagnostic URL paths --- .../BrowserDiagnostics.swift | 39 +++++++++++++------ .../BrowserDiagnosticsTests.swift | 6 ++- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 1a7e6d6..c0ca88c 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -24,17 +24,32 @@ public enum BrowserDiagnosticConclusion: String, Equatable { public enum DiagnosticRedactor { private static let code = try! NSRegularExpression(pattern: #"(? String { + let scheme = Range(match.range(at: 1), in: text).map { String(text[$0]) } ?? "chrome-extension" + let path = match.range(at: 2).location != NSNotFound ? (Range(match.range(at: 2), in: text).map { String(text[$0]) } ?? "") : "" + return "\(scheme)://\(path)" + } public static func redactText(_ text: String) -> String { - var s = text - s = ext.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1://$2") - s = code.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") + var out = "", cursor = text.startIndex + let range = NSRange(text.startIndex..., in: text) + for match in ext.matches(in: text, range: range) { + guard let r = Range(match.range, in: text) else { continue } + out += redactNonURL(String(text[cursor.. String { + var s = code.stringByReplacingMatches(in: text, range: NSRange(text.startIndex..., in: text), withTemplate: "") s = number.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "") return s } public static func extensionURLSummary(from text: String) -> String? { - guard let m = ext.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let r = Range(m.range, in: text) else { return nil } - return redactText(String(text[r])) + guard let m = ext.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) else { return nil } + return extensionSummary(text, match: m) } public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } } @@ -122,20 +137,20 @@ struct BrowserDiagnosticReport: Equatable { init(generatedAt: Date, ruleMode: ApplicationRuleMode, applications: [DiagnosticApplication]) { self.generatedAt = generatedAt; self.ruleMode = ruleMode; self.applications = applications } public var conclusion: BrowserDiagnosticConclusion { BrowserDiagnostics.evaluate(applications) } public var summary: String { conclusion.localizedSummary } - func plainText(policy: ApplicationRulePolicy) -> String { + private func renderPlainText() -> String { var lines = ["generatedAt=\(generatedAt.timeIntervalSince1970)", "ruleMode=\(ruleMode.rawValue)", "conclusion=\(conclusion.rawValue)", "summary=\(summary)"] - for app in applications { + for app in applications.sorted(by: { ($0.bundleIdentifier, $0.processIdentifier ?? -1) < ($1.bundleIdentifier, $1.processIdentifier ?? -1) }) { let pid = app.processIdentifier.map(String.init) ?? "nil" lines.append("app=\(app.displayName) bundle=\(app.bundleIdentifier) pid=\(pid) activation=\(app.activationPolicy ?? "nil") permitted=\(app.permitted)") - for w in app.windows { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } - for ax in app.accessibilityWindows { + for w in app.windows.sorted(by: { $0.number < $1.number }) { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } + for ax in app.accessibilityWindows.sorted(by: { (DiagnosticRedactor.redactTitle($0.title), $0.extensionURLSummary ?? "") < (DiagnosticRedactor.redactTitle($1.title), $1.extensionURLSummary ?? "") }) { let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") - lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary.map(DiagnosticRedactor.redactText) ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") } } return lines.joined(separator: "\n") } - public var plainText: String { plainText(policy: ApplicationRulePolicy(mode: ruleMode, allowlist: [], denylist: [])) } + public var plainText: String { renderPlainText() } } protocol BrowserDiagnosticsRunning { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 9b237cf..526f268 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -68,6 +68,9 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(DiagnosticRedactor.redactText("code 123456 and 1 2 3 4 5 6 and 1-2-3-4-5-6"), "code and and ") let ext = "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42" XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: ext), "chrome-extension:///page_popup.html") + XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: "chrome-extension://id/page123.html?x=42#secret"), "chrome-extension:///page123.html") + XCTAssertEqual(DiagnosticRedactor.redactText("version 42 chrome-extension://id/page123.html?x=42#secret"), "version chrome-extension:///page123.html") + XCTAssertEqual(DiagnosticRedactor.extensionURLSummary(from: "moz-extension://id/page123.html?x=42#secret"), "moz-extension:///page123.html") XCTAssertEqual(DiagnosticRedactor.redactTitle(nil), "") XCTAssertLessThanOrEqual(DiagnosticRedactor.redactTitle(String(repeating: "a", count: 200)).count, 160) } @@ -89,9 +92,8 @@ final class BrowserDiagnosticsTests: XCTestCase { func test_report_plain_text_is_deterministic_and_redacted() { let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) - let policy = ApplicationRulePolicy(mode: .allowlist, allowlist: [app.bundleIdentifier], denylist: []) let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) - let text = report.plainText(policy: policy) + let text = report.plainText XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) } From ca98600ff29be75bd19bec8891ced5c602dae3dc Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:30:26 +0800 Subject: [PATCH 10/23] feat: collect browser recognition diagnostics --- .../BrowserDiagnostics.swift | 55 ++++++++++++++++++- Sources/ApplePasswordBridge/CodeParser.swift | 12 +++- .../BrowserDiagnosticsTests.swift | 46 ++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index c0ca88c..c7c636d 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -1,4 +1,6 @@ import Foundation +import AppKit +import CoreGraphics public enum BrowserDiagnosticConclusion: String, Equatable { case applicationNotRunning = "application_not_running" @@ -114,7 +116,10 @@ public struct DiagnosticApplication: Equatable { } } -public enum BrowserDiagnostics { +struct DiagnosticNode { let role: String; let text: String } + +public final class BrowserDiagnostics: BrowserDiagnosticsRunning { + public init() {} public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } let running = applications.filter { $0.processIdentifier != nil } @@ -128,6 +133,54 @@ public enum BrowserDiagnostics { guard matching.contains(where: { $0.supportedInputCount > 0 }) else { return .inputRolesUnrecognized } return .targetRecognized } + + static func makeAccessibilityObservation(title: String?, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { + let combined = nodes.map(\.text).joined(separator: "\n") + let all = [title ?? "", combined].joined(separator: "\n") + let lower = all.lowercased() + let hasURL = lower.contains("chrome-extension://") || lower.contains("moz-extension://") + let popup = hasURL && lower.contains("/page_popup.html") + let icloud = AuthorizationContext.isICloudPasswordWindowTitle(title) || lower.contains("icloud 密码") || lower.contains("icloud passwords") + var roles: [String: Int] = [:] + for n in nodes { roles[n.role, default: 0] += 1 } + return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined)) + } + + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + let apps: [NSRunningApplication] + let targets: [TargetApplication] + switch policy.mode { + case .allowlist: targets = configuredApplications + case .denylist: + let running = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular && policy.permits(bundleIdentifier: $0.bundleIdentifier ?? "") } + apps = running.sorted { (($0.bundleIdentifier ?? ""), $0.processIdentifier) < (($1.bundleIdentifier ?? ""), $1.processIdentifier) }.prefix(12).map { $0 } + targets = apps.compactMap { guard let b = $0.bundleIdentifier else { return nil }; return TargetApplication(bundleIdentifier: b, displayName: $0.localizedName ?? b) } + } + let cg = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] + var result: [DiagnosticApplication] = [] + for target in targets { + let app = NSRunningApplication.runningApplications(withBundleIdentifier: target.bundleIdentifier).first + let permitted = policy.permits(bundleIdentifier: target.bundleIdentifier) + let pid = app?.processIdentifier + let windows = cg.filter { ($0[kCGWindowOwnerPID as String] as? pid_t) == pid }.compactMap { info -> DiagnosticWindow? in + guard let num = info[kCGWindowNumber as String] as? UInt32, let bounds = info[kCGWindowBounds as String] as? [String: Any], let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } + let raw = info[kCGWindowName as String] as? String + return DiagnosticWindow(number: num, width: Int(rect.width), height: Int(rect.height), title: raw ?? "", titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(raw)) + } + var axWindows: [DiagnosticAccessibilityWindow] = [] + if let app, permitted { + let ax = AXUIElementCreateApplication(app.processIdentifier) + AccessibilityTree.enableEnhancedUserInterface(ax) + for w in AccessibilityTree.windows(of: ax) { + let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } + axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) + } + } + let activation: String? = app.map { switch $0.activationPolicy { case .regular: return "regular"; case .accessory: return "accessory"; case .prohibited: return "prohibited"; default: return "unknown" } } + result.append(DiagnosticApplication(displayName: target.displayName, bundleIdentifier: target.bundleIdentifier, processIdentifier: pid, activationPolicy: activation, permitted: permitted, windows: windows, accessibilityWindows: axWindows)) + } + return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: result) + } } struct BrowserDiagnosticReport: Equatable { diff --git a/Sources/ApplePasswordBridge/CodeParser.swift b/Sources/ApplePasswordBridge/CodeParser.swift index cc87ca8..60ec688 100644 --- a/Sources/ApplePasswordBridge/CodeParser.swift +++ b/Sources/ApplePasswordBridge/CodeParser.swift @@ -9,10 +9,18 @@ enum AuthorizationContext { "验证码", "verification code" ] - static func isApplePasswordAuthorization(_ text: String) -> Bool { + static func hasAutofillTerms(_ text: String) -> Bool { let value = text.lowercased() return autofillTerms.contains(where: value.contains) - && codeTerms.contains(where: value.contains) + } + + static func hasVerificationCodeTerms(_ text: String) -> Bool { + let value = text.lowercased() + return codeTerms.contains(where: value.contains) + } + + static func isApplePasswordAuthorization(_ text: String) -> Bool { + return hasAutofillTerms(text) && hasVerificationCodeTerms(text) } static func isBrowserExtensionAuthorization(title: String?, text: String) -> Bool { diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 526f268..cb7f9a8 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -100,4 +100,50 @@ final class BrowserDiagnosticsTests: XCTestCase { func test_all_conclusions_have_chinese_summary() { for c in [BrowserDiagnosticConclusion.applicationNotRunning,.applicationRejectedByPolicy,.noVisibleWindows,.windowTitleMismatch,.accessibilityWindowsUnavailable,.authorizationContextMismatch,.inputRolesUnrecognized,.targetRecognized] { XCTAssertFalse(c.localizedSummary.isEmpty) } } + + func test_arc_allowlist_policy_permits_arc() { + let policy = ApplicationRulePolicy(mode: .allowlist, allowlist: ["company.thebrowser.Browser"], denylist: []) + XCTAssertTrue(policy.permits(bundleIdentifier: "company.thebrowser.Browser")) + } + + func test_accessibility_observation_extracts_redacted_extension_and_roles() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: "iCloud Passwords", + nodes: [ + DiagnosticNode(role: "AXStaticText", text: "chrome-extension://secret-id/page_popup.html"), + DiagnosticNode(role: "AXTextField", text: "") + ] + ) + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) + XCTAssertTrue(observation.hasICloudIdentity) + XCTAssertEqual(observation.roleCounts["AXStaticText"], 1) + XCTAssertEqual(observation.roleCounts["AXTextField"], 1) + XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertFalse(String(describing: observation).contains("secret-id")) + } + + func test_popup_signature_and_authorization_context_are_independent() { + let popup = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "chrome-extension://id/page_popup.html")]) + XCTAssertTrue(popup.stablePopupSignature) + XCTAssertFalse(popup.authorizationContextMatches) + + let context = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "自动填充 verification code")]) + XCTAssertFalse(context.stablePopupSignature) + XCTAssertTrue(context.authorizationContextMatches) + } + + func test_observation_description_excludes_raw_combined_body() { + let observation = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "RAW-PRIVATE-AX-BODY")]) + XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-AX-BODY")) + } + + func test_code_parser_terms_support_chinese_and_english_without_changing_authorization_behavior() { + XCTAssertTrue(AuthorizationContext.hasAutofillTerms("请使用自动填充")) + XCTAssertTrue(AuthorizationContext.hasAutofillTerms("browser autofill")) + XCTAssertTrue(AuthorizationContext.hasVerificationCodeTerms("输入验证码")) + XCTAssertTrue(AuthorizationContext.hasVerificationCodeTerms("verification code")) + XCTAssertTrue(AuthorizationContext.isApplePasswordAuthorization("自动填充 verification code")) + XCTAssertFalse(AuthorizationContext.isApplePasswordAuthorization("自动填充 only")) + } } From 479172d871c081bee869039acd57474a1972bc45 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:39:48 +0800 Subject: [PATCH 11/23] fix: keep browser diagnostics read only --- .../BrowserDiagnostics.swift | 34 ++++++++++++------- .../BrowserDiagnosticsTests.swift | 23 +++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index c7c636d..7b3f68d 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -146,31 +146,39 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning { return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined)) } + static func makeWindowObservation(info: [String: Any], expectedPID: pid_t) -> DiagnosticWindow? { + guard let owner = info[kCGWindowOwnerPID as String] as? NSNumber, + owner.int32Value == Int32(expectedPID), + let numValue = info[kCGWindowNumber as String] as? NSNumber, + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } + let raw = info[kCGWindowName as String] as? String + return DiagnosticWindow(number: numValue.uint32Value, width: Int(rect.width), height: Int(rect.height), title: raw ?? "", titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(raw)) + } + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { - let apps: [NSRunningApplication] - let targets: [TargetApplication] + let pairs: [(TargetApplication, NSRunningApplication?)] switch policy.mode { - case .allowlist: targets = configuredApplications + case .allowlist: + pairs = configuredApplications.map { target in + (target, NSRunningApplication.runningApplications(withBundleIdentifier: target.bundleIdentifier).first) + } case .denylist: let running = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular && policy.permits(bundleIdentifier: $0.bundleIdentifier ?? "") } - apps = running.sorted { (($0.bundleIdentifier ?? ""), $0.processIdentifier) < (($1.bundleIdentifier ?? ""), $1.processIdentifier) }.prefix(12).map { $0 } - targets = apps.compactMap { guard let b = $0.bundleIdentifier else { return nil }; return TargetApplication(bundleIdentifier: b, displayName: $0.localizedName ?? b) } + pairs = running.sorted { (($0.bundleIdentifier ?? ""), $0.processIdentifier) < (($1.bundleIdentifier ?? ""), $1.processIdentifier) }.prefix(12).compactMap { app in + guard let b = app.bundleIdentifier else { return nil } + return (TargetApplication(bundleIdentifier: b, displayName: app.localizedName ?? b), app) + } } let cg = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] var result: [DiagnosticApplication] = [] - for target in targets { - let app = NSRunningApplication.runningApplications(withBundleIdentifier: target.bundleIdentifier).first + for (target, app) in pairs { let permitted = policy.permits(bundleIdentifier: target.bundleIdentifier) let pid = app?.processIdentifier - let windows = cg.filter { ($0[kCGWindowOwnerPID as String] as? pid_t) == pid }.compactMap { info -> DiagnosticWindow? in - guard let num = info[kCGWindowNumber as String] as? UInt32, let bounds = info[kCGWindowBounds as String] as? [String: Any], let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } - let raw = info[kCGWindowName as String] as? String - return DiagnosticWindow(number: num, width: Int(rect.width), height: Int(rect.height), title: raw ?? "", titleMatches: AuthorizationContext.isICloudPasswordWindowTitle(raw)) - } + let windows = pid.map { expected in cg.compactMap { Self.makeWindowObservation(info: $0, expectedPID: expected) } } ?? [] var axWindows: [DiagnosticAccessibilityWindow] = [] if let app, permitted { let ax = AXUIElementCreateApplication(app.processIdentifier) - AccessibilityTree.enableEnhancedUserInterface(ax) for w in AccessibilityTree.windows(of: ax) { let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index cb7f9a8..1b43e4f 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -133,6 +133,29 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertTrue(context.authorizationContextMatches) } + func test_make_window_observation_parses_number_pid_bounds_and_redacts_title() { + let info: [String: Any] = [ + kCGWindowOwnerPID as String: NSNumber(value: 42), + kCGWindowNumber as String: NSNumber(value: UInt32(99)), + kCGWindowBounds as String: ["X": 0, "Y": 0, "Width": 640, "Height": 480], + kCGWindowName as String: "code 123456" + ] + let result = BrowserDiagnostics.makeWindowObservation(info: info, expectedPID: 42) + XCTAssertEqual(result?.number, 99) + XCTAssertEqual(result?.width, 640) + XCTAssertEqual(result?.height, 480) + XCTAssertTrue(result?.title.contains("") == true) + } + + func test_make_window_observation_rejects_pid_mismatch() { + let info: [String: Any] = [ + kCGWindowOwnerPID as String: NSNumber(value: 7), + kCGWindowNumber as String: NSNumber(value: UInt32(1)), + kCGWindowBounds as String: ["X": 0, "Y": 0, "Width": 1, "Height": 1] + ] + XCTAssertNil(BrowserDiagnostics.makeWindowObservation(info: info, expectedPID: 42)) + } + func test_observation_description_excludes_raw_combined_body() { let observation = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "RAW-PRIVATE-AX-BODY")]) XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-AX-BODY")) From 49def5e99c9ae8bd1d049926f5d6417798ffcb16 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:43:19 +0800 Subject: [PATCH 12/23] feat: expose one-shot browser diagnostics --- .../ApplicationRules.swift | 6 ++-- Sources/ApplePasswordBridge/BridgeModel.swift | 30 +++++++++++++++++-- .../BrowserDiagnostics.swift | 16 +++++----- .../BrowserDiagnosticsTests.swift | 24 +++++++++++++++ 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/Sources/ApplePasswordBridge/ApplicationRules.swift b/Sources/ApplePasswordBridge/ApplicationRules.swift index cb986c3..c00dda6 100644 --- a/Sources/ApplePasswordBridge/ApplicationRules.swift +++ b/Sources/ApplePasswordBridge/ApplicationRules.swift @@ -24,7 +24,7 @@ enum FillSpeed: String, CaseIterable, Identifiable { } } -enum ApplicationRuleMode: String, CaseIterable, Identifiable { +enum ApplicationRuleMode: String, CaseIterable, Identifiable, Sendable { case allowlist case denylist @@ -38,7 +38,7 @@ enum ApplicationRuleMode: String, CaseIterable, Identifiable { } } -struct TargetApplication: Codable, Hashable, Identifiable { +struct TargetApplication: Codable, Hashable, Identifiable, Sendable { let bundleIdentifier: String let displayName: String @@ -50,7 +50,7 @@ struct TargetApplication: Codable, Hashable, Identifiable { ) } -struct ApplicationRulePolicy { +struct ApplicationRulePolicy: Sendable { let mode: ApplicationRuleMode let allowlist: Set let denylist: Set diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index 2da2e03..a7ec9be 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -27,6 +27,8 @@ final class BridgeModel: ObservableObject { @Published private(set) var launchAtLogin = false @Published private(set) var statusText = "正在启动" @Published private(set) var isWorking = false + @Published private(set) var diagnosticReport: BrowserDiagnosticReport? + @Published private(set) var isDiagnosing = false private enum Keys { static let monitoring = "monitoringEnabled" @@ -40,6 +42,7 @@ final class BridgeModel: ObservableObject { private let codeReader = PasswordCodeReader() private let browserAutofill = BrowserAutofill() + private let browserDiagnostics: any BrowserDiagnosticsRunning private var scanTimer: Timer? private var hotKey: GlobalHotKey? private var currentCode: CapturedCode? @@ -51,7 +54,8 @@ final class BridgeModel: ObservableObject { private var manualRequestPending = false private var started = false - init() { + init(browserDiagnostics: any BrowserDiagnosticsRunning = BrowserDiagnostics(), startAutomatically: Bool = true) { + self.browserDiagnostics = browserDiagnostics UserDefaults.standard.register(defaults: [ Keys.monitoring: true, Keys.automaticFill: true, @@ -76,11 +80,31 @@ final class BridgeModel: ObservableObject { key: Keys.denylistedApplications, fallback: [] ) - Task { @MainActor [weak self] in - self?.start() + if startAutomatically { + Task { @MainActor [weak self] in self?.start() } } } + func runDiagnostics() async { + guard !isDiagnosing else { return } + isDiagnosing = true + defer { isDiagnosing = false } + let policy = applicationPolicy + let applications = activeApplicationRules + let now = Date() + let runner = browserDiagnostics + let report = await Task.detached(priority: .userInitiated) { + runner.run(policy: policy, configuredApplications: applications, now: now) + }.value + diagnosticReport = report + } + + func copyDiagnosticReport() { + guard let report = diagnosticReport else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report.plainText, forType: .string) + } + func start() { guard !started else { return } started = true diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 7b3f68d..8cd7d2d 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -2,7 +2,7 @@ import Foundation import AppKit import CoreGraphics -public enum BrowserDiagnosticConclusion: String, Equatable { +public enum BrowserDiagnosticConclusion: String, Equatable, Sendable { case applicationNotRunning = "application_not_running" case applicationRejectedByPolicy = "application_rejected_by_policy" case noVisibleWindows = "no_visible_windows" @@ -56,7 +56,7 @@ public enum DiagnosticRedactor { public static func redactTitle(_ title: String?) -> String { String(redactText(title ?? "").prefix(160)) } } -public struct DiagnosticWindow: Equatable { +public struct DiagnosticWindow: Equatable, Sendable { public let number: UInt32 public let width: Int public let height: Int @@ -71,7 +71,7 @@ public struct DiagnosticWindow: Equatable { } } -public struct DiagnosticAccessibilityWindow: Equatable { +public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let title: String public let extensionURLSummary: String? public let hasExtensionURL: Bool @@ -97,7 +97,7 @@ public struct DiagnosticAccessibilityWindow: Equatable { public var supportedInputCount: Int { ["AXTextField", "AXTextArea", "AXSecureTextField"].reduce(0) { $0 + (roleCounts[$1] ?? 0) } } } -public struct DiagnosticApplication: Equatable { +public struct DiagnosticApplication: Equatable, Sendable { public let displayName: String public let bundleIdentifier: String public let processIdentifier: pid_t? @@ -116,9 +116,9 @@ public struct DiagnosticApplication: Equatable { } } -struct DiagnosticNode { let role: String; let text: String } +struct DiagnosticNode: Sendable { let role: String; let text: String } -public final class BrowserDiagnostics: BrowserDiagnosticsRunning { +public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sendable { public init() {} public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } @@ -191,7 +191,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning { } } -struct BrowserDiagnosticReport: Equatable { +struct BrowserDiagnosticReport: Equatable, Sendable { public let generatedAt: Date public let ruleMode: ApplicationRuleMode public let applications: [DiagnosticApplication] @@ -214,4 +214,4 @@ struct BrowserDiagnosticReport: Equatable { public var plainText: String { renderPlainText() } } -protocol BrowserDiagnosticsRunning { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } +protocol BrowserDiagnosticsRunning: Sendable { func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 1b43e4f..12d1c3e 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -1,7 +1,31 @@ import XCTest @testable import ApplePasswordBridge +private final class FakeDiagnosticsRunner: BrowserDiagnosticsRunning, @unchecked Sendable { + private let lock = NSLock() + private var reports: [BrowserDiagnosticReport] + init(_ reports: [BrowserDiagnosticReport]) { self.reports = reports } + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + lock.lock(); defer { lock.unlock() } + return reports.isEmpty ? BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) : reports.removeFirst() + } +} + final class BrowserDiagnosticsTests: XCTestCase { + @MainActor + func test_bridge_model_runs_diagnostics_and_replaces_report() async { + let first = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 1), ruleMode: .allowlist, applications: []) + let second = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 2), ruleMode: .denylist, applications: []) + let fake = FakeDiagnosticsRunner([first, second]) + let model = BridgeModel(browserDiagnostics: fake, startAutomatically: false) + XCTAssertFalse(model.isDiagnosing) + await model.runDiagnostics() + XCTAssertEqual(model.diagnosticReport, first) + XCTAssertFalse(model.isDiagnosing) + await model.runDiagnostics() + XCTAssertEqual(model.diagnosticReport, second) + XCTAssertFalse(model.isDiagnosing) + } private func application(running: Bool = true, permitted: Bool = true, windows: [DiagnosticWindow] = [], accessibilityWindows: [DiagnosticAccessibilityWindow] = []) -> DiagnosticApplication { DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: running ? 42 : nil, activationPolicy: "regular", permitted: permitted, windows: windows, accessibilityWindows: accessibilityWindows) } From 775e6eb2c92809be9e7cb775de865f7999e9ff91 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:56:42 +0800 Subject: [PATCH 13/23] fix: make diagnostic tasks cancellation aware --- Sources/ApplePasswordBridge/BridgeModel.swift | 36 +++++++++++-- .../BrowserDiagnostics.swift | 5 +- .../BrowserDiagnosticsTests.swift | 52 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index a7ec9be..bc709b4 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -3,6 +3,28 @@ import Combine import ServiceManagement import UniformTypeIdentifiers +private final class DiagnosticsTaskBox: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var cancellationRequested = false + + func set(_ task: Task) { + lock.lock() + self.task = task + let shouldCancel = cancellationRequested + lock.unlock() + if shouldCancel { task.cancel() } + } + + func cancel() { + lock.lock() + cancellationRequested = true + let task = task + lock.unlock() + task?.cancel() + } +} + @MainActor final class BridgeModel: ObservableObject { @Published var monitoringEnabled: Bool { @@ -93,9 +115,17 @@ final class BridgeModel: ObservableObject { let applications = activeApplicationRules let now = Date() let runner = browserDiagnostics - let report = await Task.detached(priority: .userInitiated) { - runner.run(policy: policy, configuredApplications: applications, now: now) - }.value + let handle = DiagnosticsTaskBox() + let report = await withTaskCancellationHandler(operation: { + let task = Task.detached(priority: .userInitiated) { + runner.run(policy: policy, configuredApplications: applications, now: now) + } + handle.set(task) + return await task.value + }, onCancel: { + handle.cancel() + }) + guard !Task.isCancelled else { return } diagnosticReport = report } diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 8cd7d2d..ef1c8a3 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -118,7 +118,7 @@ public struct DiagnosticApplication: Equatable, Sendable { struct DiagnosticNode: Sendable { let role: String; let text: String } -public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sendable { +public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { public init() {} public static func evaluate(_ applications: [DiagnosticApplication]) -> BrowserDiagnosticConclusion { guard applications.contains(where: { $0.processIdentifier != nil }) else { return .applicationNotRunning } @@ -157,6 +157,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sen } func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + if Task.isCancelled { return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) } let pairs: [(TargetApplication, NSRunningApplication?)] switch policy.mode { case .allowlist: @@ -173,6 +174,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sen let cg = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] var result: [DiagnosticApplication] = [] for (target, app) in pairs { + if Task.isCancelled { break } let permitted = policy.permits(bundleIdentifier: target.bundleIdentifier) let pid = app?.processIdentifier let windows = pid.map { expected in cg.compactMap { Self.makeWindowObservation(info: $0, expectedPID: expected) } } ?? [] @@ -180,6 +182,7 @@ public final class BrowserDiagnostics: BrowserDiagnosticsRunning, @unchecked Sen if let app, permitted { let ax = AXUIElementCreateApplication(app.processIdentifier) for w in AccessibilityTree.windows(of: ax) { + if Task.isCancelled { break } let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 12d1c3e..b04b95f 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -11,6 +11,26 @@ private final class FakeDiagnosticsRunner: BrowserDiagnosticsRunning, @unchecked } } +private final class BlockingDiagnosticsRunner: BrowserDiagnosticsRunning, @unchecked Sendable { + private let lock = NSLock() + private var released = false + private var calls = 0 + private var cancellationObserved = false + var callCount: Int { lock.lock(); defer { lock.unlock() }; return calls } + var sawCancellation: Bool { lock.lock(); defer { lock.unlock() }; return cancellationObserved } + func release() { lock.lock(); released = true; lock.unlock() } + func run(policy: ApplicationRulePolicy, configuredApplications: [TargetApplication], now: Date) -> BrowserDiagnosticReport { + lock.lock(); calls += 1; lock.unlock() + while true { + if Task.isCancelled { lock.lock(); cancellationObserved = true; lock.unlock(); break } + lock.lock(); let done = released; lock.unlock() + if done { break } + Thread.sleep(forTimeInterval: 0.001) + } + return BrowserDiagnosticReport(generatedAt: now, ruleMode: policy.mode, applications: []) + } +} + final class BrowserDiagnosticsTests: XCTestCase { @MainActor func test_bridge_model_runs_diagnostics_and_replaces_report() async { @@ -26,6 +46,38 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(model.diagnosticReport, second) XCTAssertFalse(model.isDiagnosing) } + + @MainActor + func test_concurrent_diagnostics_request_is_ignored_until_first_finishes() async { + let fake = BlockingDiagnosticsRunner() + let model = BridgeModel(browserDiagnostics: fake, startAutomatically: false) + let first = Task { @MainActor in await model.runDiagnostics() } + for _ in 0..<200 where fake.callCount == 0 { await Task.yield() } + XCTAssertEqual(fake.callCount, 1) + await model.runDiagnostics() + XCTAssertEqual(fake.callCount, 1) + XCTAssertTrue(model.isDiagnosing) + fake.release() + await first.value + XCTAssertFalse(model.isDiagnosing) + XCTAssertNotNil(model.diagnosticReport) + } + + @MainActor + func test_cancelling_diagnostics_observes_cancellation_and_does_not_publish_report() async { + let fake = BlockingDiagnosticsRunner() + let model = BridgeModel(browserDiagnostics: fake, startAutomatically: false) + let task = Task { @MainActor in await model.runDiagnostics() } + for _ in 0..<200 where fake.callCount == 0 { await Task.yield() } + XCTAssertEqual(fake.callCount, 1) + task.cancel() + for _ in 0..<500 where !fake.sawCancellation { await Task.yield() } + XCTAssertTrue(fake.sawCancellation) + fake.release() + await task.value + XCTAssertFalse(model.isDiagnosing) + XCTAssertNil(model.diagnosticReport) + } private func application(running: Bool = true, permitted: Bool = true, windows: [DiagnosticWindow] = [], accessibilityWindows: [DiagnosticAccessibilityWindow] = []) -> DiagnosticApplication { DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: running ? 42 : nil, activationPolicy: "regular", permitted: permitted, windows: windows, accessibilityWindows: accessibilityWindows) } From 33025679e32cc6b64f29cde04456283efee48f09 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:59:54 +0800 Subject: [PATCH 14/23] feat: show browser diagnostics in menu --- Sources/ApplePasswordBridge/BridgeApp.swift | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Sources/ApplePasswordBridge/BridgeApp.swift b/Sources/ApplePasswordBridge/BridgeApp.swift index 64fa9d2..52790c3 100644 --- a/Sources/ApplePasswordBridge/BridgeApp.swift +++ b/Sources/ApplePasswordBridge/BridgeApp.swift @@ -122,6 +122,40 @@ private struct BridgeMenu: View { } } + Divider() + + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await model.runDiagnostics() } + } label: { + Label( + model.isDiagnosing ? "正在诊断…" : "诊断当前授权窗口", + systemImage: "stethoscope" + ) + .frame(maxWidth: .infinity) + } + .disabled(model.isDiagnosing) + + if let report = model.diagnosticReport { + Text(report.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + + HStack { + Text(report.generatedAt, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Button("复制诊断报告", action: model.copyDiagnosticReport) + .controlSize(.small) + } + } + } + + Divider() + VStack(alignment: .leading, spacing: 8) { PermissionRow( title: "辅助功能", From 7465a989e603ed7da7da8585d2f807f6cc6dddaf Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:00:14 +0800 Subject: [PATCH 15/23] docs: design Arc popup fallback --- ...8-04-arc-untitled-popup-fallback-design.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md diff --git a/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md new file mode 100644 index 0000000..b128d01 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md @@ -0,0 +1,59 @@ +# Arc Untitled Popup Fallback Design + +## Problem + +Arc exposes its iCloud Passwords authorization popup differently across macOS APIs. In the captured failure, Core Graphics reported an untitled `376×189` popup and a `Control Center` main window, while Accessibility exposed a matching authorization context with six text fields. The current autofill path requires a Core Graphics title containing `iCloud` and `密码` or `password` before it inspects Accessibility, so it rejects Arc before the authoritative evidence is evaluated. + +The diagnostic evaluator repeats the same ordering and reports `window_title_mismatch` even when Accessibility has already recognized the target. + +## Chosen Approach + +Keep the existing title match as the preferred fast path. For each eligible running application, if no titled authorization window exists, admit at most one compact on-screen window as a fallback candidate: + +- width from 200 through 700 points; +- height from 100 through 500 points; +- if multiple titled matches exist, choose the lowest window number; +- if multiple compact windows exist, choose the smallest area and then the lowest window number for deterministic ordering. + +The fallback is only a request to inspect the application's Accessibility tree. It is not sufficient authorization evidence. `locateTarget` must still find either the existing stable popup signature or the existing authorization-context signature, plus at least one supported text input. No Arc bundle-ID special case and no empty-title special case will be added. + +Only one candidate per process is returned. This preserves the existing `WindowIdentity`-based handled/retry bookkeeping and prevents one browser with several windows from being filled repeatedly under different identities. + +## Diagnostic Semantics + +A positive Accessibility target is stronger evidence than a missing Core Graphics title. The evaluator will therefore return: + +1. `target_recognized` when a matching Accessibility window has supported inputs, even if every Core Graphics title mismatches; +2. `input_roles_unrecognized` when a matching Accessibility context exists without supported inputs; +3. `window_title_mismatch` only when no Accessibility target is recognized and no Core Graphics title matches. + +The remaining failure boundaries retain their current meanings. Raw Accessibility text, verification codes, and extension identifiers remain excluded from reports. + +## Data Flow + +1. Resolve running applications permitted by the application policy. +2. Read on-screen Core Graphics windows once. +3. Group usable windows by eligible PID and choose one preferred identity per process: titled match first, compact fallback second. +4. Warm up Accessibility and inspect all AX windows for each selected application. +5. Accept only the existing authorization predicates and supported input roles. +6. Reuse the selected identity for retry suppression and handled-window bookkeeping. + +## Tests + +The regression suite will first reproduce the failure before production changes: + +- candidate selection accepts one Arc fallback from a large `Control Center` window plus an untitled `376×189` popup, selecting the compact window; +- candidate selection still prefers a correctly titled iCloud Passwords window; +- a large untitled browser window alone does not become a candidate; +- multiple windows from one PID yield only one candidate; +- diagnostic evaluation treats mismatched CG titles plus a matching AX context with six inputs as `target_recognized`; +- existing Firefox title-based behavior remains green. + +After automated verification, rebuild the diagnostic application and repeat the same Arc scenario. The expected diagnostic conclusion is `target_recognized`, followed by a real manual-fill check. + +## Non-Goals + +- Replacing Core Graphics identities with private AX-to-CG window APIs. +- Continuously scanning the full Accessibility tree for every permitted browser. +- Adding Arc-specific bundle IDs, localized titles, or fixed window-size equality checks. +- Changing code capture, keyboard event generation, fill timing, or application-rule behavior. From b86f16caa100e00e6efb4705258b32f5b6d6ba20 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:08:46 +0800 Subject: [PATCH 16/23] docs: revise Arc fallback security design --- ...8-04-arc-untitled-popup-fallback-design.md | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md index b128d01..bd8340a 100644 --- a/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md +++ b/docs/superpowers/specs/2026-08-04-arc-untitled-popup-fallback-design.md @@ -1,59 +1,60 @@ -# Arc Untitled Popup Fallback Design +# Arc Authorization Origin Diagnostics Design -## Problem +## Decision -Arc exposes its iCloud Passwords authorization popup differently across macOS APIs. In the captured failure, Core Graphics reported an untitled `376×189` popup and a `Control Center` main window, while Accessibility exposed a matching authorization context with six text fields. The current autofill path requires a Core Graphics title containing `iCloud` and `密码` or `password` before it inspects Accessibility, so it rejects Arc before the authoritative evidence is evaluated. +This document supersedes the earlier compact-window fallback proposal. Window dimensions are not a security identity and may change with Arc releases, localization, display scale, and UI layout. No size-based fallback will be added to automatic filling at this stage. -The diagnostic evaluator repeats the same ordering and reports `window_title_mismatch` even when Accessibility has already recognized the target. +The next build is diagnostic-only. It will determine whether Arc exposes a stable extension origin or structural Accessibility attributes that can replace the brittle Core Graphics title requirement without trusting page-controlled text. -## Chosen Approach +## Evidence and Risk -Keep the existing title match as the preferred fast path. For each eligible running application, if no titled authorization window exists, admit at most one compact on-screen window as a fallback candidate: +The captured Arc report showed an untitled `376×189` Core Graphics window, but its Accessibility tree contained an iCloud identity, autofill and verification-code terms, and six text fields. The existing production path never inspected that tree because the Core Graphics title did not contain `iCloud` and `密码` or `password`. -- width from 200 through 700 points; -- height from 100 through 500 points; -- if multiple titled matches exist, choose the lowest window number; -- if multiple compact windows exist, choose the smallest area and then the lowest window number for deterministic ordering. +Simply admitting compact windows would reduce defense in depth. The current content-based Accessibility predicate can theoretically be imitated by a web page inside an allowed browser. A malicious page containing the expected phrases and six inputs must not become trusted merely because its window is small. -The fallback is only a request to inspect the application's Accessibility tree. It is not sufficient authorization evidence. `locateTarget` must still find either the existing stable popup signature or the existing authorization-context signature, plus at least one supported text input. No Arc bundle-ID special case and no empty-title special case will be added. +The Accessibility collector already requests `AXURL`, but it only retains values that cast directly to `String`. macOS may expose that attribute as `URL`, `NSURL`, or `CFURL`; those values are currently discarded, which can explain `extension=nil` in the report. -Only one candidate per process is returned. This preserves the existing `WindowIdentity`-based handled/retry bookkeeping and prevents one browser with several windows from being filled repeatedly under different identities. +## Diagnostic Changes -## Diagnostic Semantics +Extend the read-only Accessibility observation model to capture these signals without changing focus, activation, keyboard events, or clipboard behavior: -A positive Accessibility target is stronger evidence than a missing Core Graphics title. The evaluator will therefore return: +- normalize `AXURL` values supplied as `String`, `URL`, `NSURL`, or `CFURL`; +- retain only a redacted extension URL summary: scheme plus path, with host, query, and fragment removed; +- capture the AX window role and subrole; +- capture whether `AXModal`, `AXMain`, and `AXFocused` are present and true; +- continue reporting role counts, existing authorization flags, and supported input count; +- never retain or render raw AX body text, extension identifiers, query values, verification codes, or input values. -1. `target_recognized` when a matching Accessibility window has supported inputs, even if every Core Graphics title mismatches; -2. `input_roles_unrecognized` when a matching Accessibility context exists without supported inputs; -3. `window_title_mismatch` only when no Accessibility target is recognized and no Core Graphics title matches. +URL normalization must be exposed as a small pure helper so each supported representation can be unit tested. Production authorization matching will not consume the newly normalized URL during this diagnostic phase. -The remaining failure boundaries retain their current meanings. Raw Accessibility text, verification codes, and extension identifiers remain excluded from reports. +## Data Flow and Isolation -## Data Flow +`BrowserDiagnostics` will read the additional attributes from each observed AX window and node. The diagnostic model will store only normalized booleans, role/subrole strings, and the already-redacted extension summary. `BrowserAutofill.authorizationWindowCandidates`, `locateTarget`, fill timing, retry bookkeeping, and application rules remain unchanged. -1. Resolve running applications permitted by the application policy. -2. Read on-screen Core Graphics windows once. -3. Group usable windows by eligible PID and choose one preferred identity per process: titled match first, compact fallback second. -4. Warm up Accessibility and inspect all AX windows for each selected application. -5. Accept only the existing authorization predicates and supported input roles. -6. Reuse the selected identity for retry suppression and handled-window bookkeeping. +The diagnostic conclusion ordering remains unchanged in this phase. The new attributes explain why AX recognized the authorization context despite a CG title mismatch, but the result is not used to authorize filling. ## Tests -The regression suite will first reproduce the failure before production changes: +Add failing tests before implementation for: -- candidate selection accepts one Arc fallback from a large `Control Center` window plus an untitled `376×189` popup, selecting the compact window; -- candidate selection still prefers a correctly titled iCloud Passwords window; -- a large untitled browser window alone does not become a candidate; -- multiple windows from one PID yield only one candidate; -- diagnostic evaluation treats mismatched CG titles plus a matching AX context with six inputs as `target_recognized`; -- existing Firefox title-based behavior remains green. +- `AXURL` normalization from `String`, `URL`, and `NSURL` values; +- extension host, query, and fragment removal while preserving scheme and path; +- window role/subrole and modal/main/focused flags in deterministic report output; +- absence of raw URL hosts, six-digit codes, and raw AX text in stored observations and rendered reports; +- no changes to existing BrowserAutofill candidate-selection behavior. -After automated verification, rebuild the diagnostic application and repeat the same Arc scenario. The expected diagnostic conclusion is `target_recognized`, followed by a real manual-fill check. +Run the full test suite and release build, package a new diagnostic application, and repeat the same Arc scenario. + +## Decision Gate After the New Report + +If Arc exposes a stable `chrome-extension://` URL with `/page_popup.html`, the subsequent production design can require that origin signal plus supported inputs as the fallback authorization predicate. Window size may be used only to reduce scanning work, never to establish trust. + +If Arc exposes no stable origin URL, do not weaken authorization based on dimensions or page text alone. Use the newly captured role, subrole, modal, main, and focused evidence to design another narrowly scoped diagnostic or seek a stronger platform identity signal before changing automatic filling. ## Non-Goals -- Replacing Core Graphics identities with private AX-to-CG window APIs. -- Continuously scanning the full Accessibility tree for every permitted browser. -- Adding Arc-specific bundle IDs, localized titles, or fixed window-size equality checks. -- Changing code capture, keyboard event generation, fill timing, or application-rule behavior. +- Enabling Arc automatic filling in this diagnostic-only change. +- Trusting window dimensions, empty titles, or Arc-specific bundle identifiers. +- Using private AX-to-CG window APIs. +- Recording raw Accessibility content or password-extension identifiers. +- Changing code capture, keyboard event generation, application focus, or fill timing. From 972d8bbebd74b40b6de4720de748d0bd3c376d01 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:13:20 +0800 Subject: [PATCH 17/23] docs: plan Arc origin diagnostics --- .../2026-08-04-arc-origin-diagnostics.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md diff --git a/docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md b/docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md new file mode 100644 index 0000000..bb2d72b --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-arc-origin-diagnostics.md @@ -0,0 +1,371 @@ +# Arc Authorization Origin Diagnostics 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:** Produce a diagnostic-only build that correctly observes Arc extension URL values and non-content AX window structure without weakening automatic-fill authorization. + +**Architecture:** Add a pure Accessibility value normalizer and retain normalized URLs in a diagnostic-only `AccessibilityNode` field while preserving the existing production text path. Extend diagnostic observations with redacted origin and window metadata, then wire the live collector to those read-only attributes. No candidate discovery, focus, event, retry, or fill behavior changes. + +**Tech Stack:** Swift 5.10, AppKit/ApplicationServices Accessibility APIs, Core Foundation URL bridging, XCTest, SwiftPM, existing deterministic redaction/reporting. + +--- + +## File Map + +- `Sources/ApplePasswordBridge/Accessibility.swift`: normalize heterogeneous AX URL values and expose read-only window metadata helpers; preserve existing production text collection. +- `Sources/ApplePasswordBridge/BrowserDiagnostics.swift`: store only redacted origin summaries and structural flags in diagnostic observations and reports; wire live collection. +- `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift`: prove URL representation support, redaction, structure rendering, and no raw sensitive data retention. +- `dist/Password-Bridge-Arc-Origin-Diagnostics.zip`: ignored build artifact produced only after verification. + +### Task 1: Normalize AX URL values without changing production matching + +**Files:** +- Modify: `Sources/ApplePasswordBridge/Accessibility.swift:4-76` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing URL normalization tests** + +Add tests for all representations promised by the design: + +```swift +func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { + let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" + let foundationURL = URL(string: raw)! + let nsURL = foundationURL as NSURL + let cfURL = CFURLCreateWithString(nil, raw as CFString, nil)! + + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: raw), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: foundationURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: nsURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: cfURL), raw) + XCTAssertNil(AccessibilityValueNormalizer.urlString(from: NSNumber(value: 42))) +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build \ + --filter BrowserDiagnosticsTests/test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl +``` + +Expected: compilation fails because `AccessibilityValueNormalizer` does not exist. This is the missing behavior under test, not a test typo. + +- [ ] **Step 3: Add the minimal pure normalizer** + +Add beside `AccessibilityNode`: + +```swift +enum AccessibilityValueNormalizer { + static func urlString(from value: Any?) -> String? { + if let string = value as? String { return string } + if let url = value as? URL { return url.absoluteString } + if let url = value as? NSURL { return url.absoluteString } + if let url = value as? CFURL { return (url as URL).absoluteString } + return nil + } +} +``` + +If Swift bridging makes one URL case subsume another, keep the explicit tests and use the smallest warning-free implementation that passes all four assertions. + +- [ ] **Step 4: Add a diagnostic-only node URL field** + +Extend the node without changing how `text` is built: + +```swift +struct AccessibilityNode { + let element: AXUIElement + let role: String + let text: String + let value: String? + let position: CGPoint? + let diagnosticURL: String? +} +``` + +In `collect`, read `AXURL` separately and normalize it: + +```swift +let rawURL = copy(element, attribute: "AXURL" as CFString) +let diagnosticURL = AccessibilityValueNormalizer.urlString(from: rawURL) +``` + +Pass `diagnosticURL` to the node initializer, but leave `textAttributes`, `values`, and `text` unchanged. This preserves the production authorization input byte-for-byte while exposing the value only to diagnostics. + +- [ ] **Step 5: Verify GREEN and run the full suite** + +Run the focused command from Step 2, then: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build +``` + +Expected: the focused test passes and the complete suite has zero failures. Existing Firefox/context tests must remain unchanged and green. + +- [ ] **Step 6: Commit URL normalization** + +```bash +git add Sources/ApplePasswordBridge/Accessibility.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: observe accessibility URLs for diagnostics" +``` + +### Task 2: Model and render redacted AX window structure + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift:74-98,119-147,194-215` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing observation and report tests** + +Extend the test-only `DiagnosticNode` construction so the raw URL is separate from body text, then assert the stored model contains only a redacted summary: + +```swift +func test_accessibility_observation_redacts_normalized_node_url_and_keeps_structure() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: nil, + role: "AXWindow", + subrole: "AXDialog", + isModal: true, + isMain: false, + isFocused: true, + nodes: [ + DiagnosticNode( + role: "AXWebArea", + text: "RAW-PRIVATE-BODY", + diagnosticURL: "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" + ), + DiagnosticNode(role: "AXTextField", text: "", diagnosticURL: nil) + ] + ) + + XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertEqual(observation.role, "AXWindow") + XCTAssertEqual(observation.subrole, "AXDialog") + XCTAssertTrue(observation.isModal) + XCTAssertFalse(observation.isMain) + XCTAssertTrue(observation.isFocused) + XCTAssertFalse(String(describing: observation).contains("secret-id")) + XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-BODY")) +} +``` + +Add a deterministic report assertion: + +```swift +XCTAssertTrue(report.plainText.contains( + "windowRole=AXWindow windowSubrole=AXDialog modal=true main=false focused=true" +)) +XCTAssertFalse(report.plainText.contains("secret-id")) +XCTAssertFalse(report.plainText.contains("popupWindow=42")) +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build \ + --filter BrowserDiagnosticsTests/test_accessibility_observation_redacts_normalized_node_url_and_keeps_structure +``` + +Expected: compilation fails because the diagnostic node, observation initializer, and structural properties do not exist yet. + +- [ ] **Step 3: Extend the diagnostic-only value types** + +Use defaults on the observation initializer so unrelated evaluator tests stay concise: + +```swift +public let role: String +public let subrole: String +public let isModal: Bool +public let isMain: Bool +public let isFocused: Bool +``` + +Extend `DiagnosticNode`: + +```swift +struct DiagnosticNode: Sendable { + let role: String + let text: String + let diagnosticURL: String? + + init(role: String, text: String, diagnosticURL: String? = nil) { + self.role = role + self.text = text + self.diagnosticURL = diagnosticURL + } +} +``` + +In `makeAccessibilityObservation`, derive the extension summary from node URLs first, then fall back to the existing combined text. Pass only the result through `DiagnosticAccessibilityWindow`, whose initializer already redacts extension URLs at capture time: + +```swift +let rawExtensionURL = nodes.compactMap(\.diagnosticURL).first { + DiagnosticRedactor.extensionURLSummary(from: $0) != nil +} +let extensionSource = rawExtensionURL ?? combined +``` + +Do not store `nodes`, `combined`, or `rawExtensionURL` in the returned observation. + +- [ ] **Step 4: Render structural metadata deterministically** + +Extend the existing AX report line with: + +```swift +"windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused)" +``` + +Keep the current sorted application/window ordering and role-count ordering. Run the existing redaction tests to prove the host, query, code, and raw body remain absent. + +- [ ] **Step 5: Verify GREEN and the complete suite** + +Run the focused test from Step 2, then the full test command from Task 1 Step 5. + +Expected: all tests pass with zero failures and no new Swift concurrency warnings. + +- [ ] **Step 6: Commit the diagnostic model** + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: report accessibility window structure" +``` + +### Task 3: Wire live read-only AX metadata and package the enhanced diagnostic build + +**Files:** +- Modify: `Sources/ApplePasswordBridge/Accessibility.swift` +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift:159-193` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` +- Create ignored artifact: `dist/Password-Bridge-Arc-Origin-Diagnostics.zip` + +- [ ] **Step 1: Write failing pure metadata tests** + +Add a small pure boolean normalizer test before wiring live AX elements: + +```swift +func test_accessibility_boolean_normalizer_accepts_cfboolean_and_nsnumber() { + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanTrue), true) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanFalse), false) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: NSNumber(value: true)), true) + XCTAssertNil(AccessibilityValueNormalizer.bool(from: "true")) +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run the Task 1 focused command with this test name. + +Expected: compilation fails because `AccessibilityValueNormalizer.bool(from:)` does not exist. + +- [ ] **Step 3: Implement read-only metadata access** + +Add the pure boolean helper: + +```swift +static func bool(from value: Any?) -> Bool? { + guard let number = value as? NSNumber else { return nil } + return number.boolValue +} +``` + +Expose focused read-only accessors on `AccessibilityTree`; each calls the existing `copy` helper and never calls `AXUIElementSetAttributeValue`: + +```swift +static func role(of element: AXUIElement) -> String { + copy(element, attribute: kAXRoleAttribute as CFString) as? String ?? "" +} + +static func subrole(of element: AXUIElement) -> String { + copy(element, attribute: kAXSubroleAttribute as CFString) as? String ?? "" +} + +static func bool(_ element: AXUIElement, attribute: CFString) -> Bool { + AccessibilityValueNormalizer.bool(from: copy(element, attribute: attribute)) ?? false +} +``` + +- [ ] **Step 4: Wire the live collector** + +When mapping `AccessibilityNode` to `DiagnosticNode`, pass `diagnosticURL`. When building an observation, pass: + +```swift +role: AccessibilityTree.role(of: window), +subrole: AccessibilityTree.subrole(of: window), +isModal: AccessibilityTree.bool(window, attribute: kAXModalAttribute as CFString), +isMain: AccessibilityTree.bool(window, attribute: kAXMainAttribute as CFString), +isFocused: AccessibilityTree.bool(window, attribute: kAXFocusedAttribute as CFString) +``` + +Confirm the collector still contains no calls to focus, raise, activate, CGEvent posting, pasteboard APIs, file writes, logging, or network APIs. + +- [ ] **Step 5: Run complete verification** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/final-build + +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/final-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/final-cache/swiftpm \ +swift build -c release --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/final-build +``` + +Expected: all tests pass and the release build exits zero. + +- [ ] **Step 6: Commit live diagnostic wiring** + +```bash +git add Sources/ApplePasswordBridge/Accessibility.swift Sources/ApplePasswordBridge/BrowserDiagnostics.swift Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: collect Arc authorization origin evidence" +``` + +- [ ] **Step 7: Package and verify** + +Run `make app`. Because the repository is under a File Provider-managed Documents directory, copy the resulting app without extended attributes, verify it, archive it, extract it, and verify the extracted copy: + +```bash +origin_package_dir=$(mktemp -d /private/tmp/apple-password-bridge-origin.XXXXXX) +ditto --norsrc --noextattr --noqtn --noacl \ + "dist/Password Bridge.app" "$origin_package_dir/Password Bridge.app" +codesign --verify --deep --strict "$origin_package_dir/Password Bridge.app" + +ditto -c -k --norsrc --noextattr --noqtn --noacl --keepParent \ + "$origin_package_dir/Password Bridge.app" \ + "dist/Password-Bridge-Arc-Origin-Diagnostics.zip" + +origin_verify_dir=$(mktemp -d /private/tmp/apple-password-bridge-origin-verify.XXXXXX) +ditto -x -k "dist/Password-Bridge-Arc-Origin-Diagnostics.zip" "$origin_verify_dir" +codesign --verify --deep --strict "$origin_verify_dir/Password Bridge.app" +``` + +Expected: both `codesign` commands exit zero. + +- [ ] **Step 8: Repeat Arc evidence collection** + +Launch the verified app, keep the same Arc authorization popup visible, click `诊断当前授权窗口`, copy the report, and check: + +- whether `extension=` now contains `chrome-extension:///page_popup.html`; +- the window role and subrole; +- modal/main/focused flags; +- six supported text inputs remain visible; +- no extension host, query, verification code, input value, or raw AX body is present. + +Do not modify automatic-fill candidate discovery until this report is reviewed. From 666785e7b38d2dde90109625d0b65ad141bd3ec8 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:35:25 +0800 Subject: [PATCH 18/23] feat: observe accessibility URLs for diagnostics --- Sources/ApplePasswordBridge/Accessibility.swift | 14 ++++++++++++++ .../BrowserDiagnosticsTests.swift | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Sources/ApplePasswordBridge/Accessibility.swift b/Sources/ApplePasswordBridge/Accessibility.swift index 494b362..675d5ea 100644 --- a/Sources/ApplePasswordBridge/Accessibility.swift +++ b/Sources/ApplePasswordBridge/Accessibility.swift @@ -1,11 +1,21 @@ import AppKit import ApplicationServices +enum AccessibilityValueNormalizer { + static func urlString(from value: Any?) -> String? { + if let string = value as? String { return string } + if let url = value as? URL { return url.absoluteString } + if let url = value as? NSURL { return url.absoluteString } + return nil + } +} + struct AccessibilityNode { let element: AXUIElement let role: String let text: String let value: String? + let diagnosticURL: String? let position: CGPoint? var isTextInput: Bool { @@ -58,11 +68,15 @@ enum AccessibilityTree { let role = (copy(element, attribute: kAXRoleAttribute as CFString) as? String) ?? "" let value = copy(element, attribute: kAXValueAttribute as CFString) as? String let values = textAttributes.compactMap { copy(element, attribute: $0) as? String } + let diagnosticURL = AccessibilityValueNormalizer.urlString( + from: copy(element, attribute: "AXURL" as CFString) + ) result.append(AccessibilityNode( element: element, role: role, text: values.joined(separator: "\n"), value: value, + diagnosticURL: diagnosticURL, position: point(element, attribute: kAXPositionAttribute as CFString) )) diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index b04b95f..1f2bc59 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -32,6 +32,18 @@ private final class BlockingDiagnosticsRunner: BrowserDiagnosticsRunning, @unche } final class BrowserDiagnosticsTests: XCTestCase { + func test_accessibility_url_normalizer_accepts_string_url_nsurl_and_cfurl() { + let raw = "chrome-extension://secret-id/page_popup.html?popupWindow=42#token" + let foundationURL = URL(string: raw)! + let nsURL = foundationURL as NSURL + let cfURL = CFURLCreateWithString(nil, raw as CFString, nil)! + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: raw), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: foundationURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: nsURL), raw) + XCTAssertEqual(AccessibilityValueNormalizer.urlString(from: cfURL), raw) + XCTAssertNil(AccessibilityValueNormalizer.urlString(from: NSNumber(value: 42))) + } + @MainActor func test_bridge_model_runs_diagnostics_and_replaces_report() async { let first = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 1), ruleMode: .allowlist, applications: []) From 0bd591952f48ecb7e7c415754e9d90bae8bca2b2 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:47:00 +0800 Subject: [PATCH 19/23] feat: report accessibility window structure --- .../BrowserDiagnostics.swift | 43 +++++++++++++++---- .../BrowserDiagnosticsTests.swift | 31 ++++++++++++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index ef1c8a3..f7f9de9 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -73,6 +73,11 @@ public struct DiagnosticWindow: Equatable, Sendable { public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let title: String + public let role: String + public let subrole: String + public let isModal: Bool + public let isMain: Bool + public let isFocused: Bool public let extensionURLSummary: String? public let hasExtensionURL: Bool public let hasPopupPath: Bool @@ -82,8 +87,9 @@ public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let roleCounts: [String: Int] public let stablePopupSignature: Bool public let authorizationContextMatches: Bool - public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool) { + public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool, role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) { self.title = DiagnosticRedactor.redactTitle(title) + self.role = role; self.subrole = subrole; self.isModal = isModal; self.isMain = isMain; self.isFocused = isFocused self.extensionURLSummary = extensionURLSummary.flatMap { DiagnosticRedactor.extensionURLSummary(from: $0) } self.hasExtensionURL = hasExtensionURL self.hasPopupPath = hasPopupPath @@ -116,7 +122,9 @@ public struct DiagnosticApplication: Equatable, Sendable { } } -struct DiagnosticNode: Sendable { let role: String; let text: String } +struct DiagnosticNode: Sendable { let role: String; let text: String; let diagnosticURL: String? + init(role: String, text: String, diagnosticURL: String? = nil) { self.role = role; self.text = text; self.diagnosticURL = diagnosticURL } +} public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { public init() {} @@ -134,16 +142,21 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { return .targetRecognized } - static func makeAccessibilityObservation(title: String?, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { + static func makeAccessibilityObservation(title: String?, nodes: [DiagnosticNode], role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) -> DiagnosticAccessibilityWindow { let combined = nodes.map(\.text).joined(separator: "\n") let all = [title ?? "", combined].joined(separator: "\n") let lower = all.lowercased() - let hasURL = lower.contains("chrome-extension://") || lower.contains("moz-extension://") - let popup = hasURL && lower.contains("/page_popup.html") + let rawDiagnosticURLs = nodes.compactMap(\.diagnosticURL) + let diagnosticURLSummary = rawDiagnosticURLs.compactMap { DiagnosticRedactor.extensionURLSummary(from: $0) }.first + let hasURL = diagnosticURLSummary != nil || lower.contains("chrome-extension://") || lower.contains("moz-extension://") + let popup = hasURL && (lower.contains("/page_popup.html") || rawDiagnosticURLs.contains { $0.lowercased().contains("/page_popup.html") }) let icloud = AuthorizationContext.isICloudPasswordWindowTitle(title) || lower.contains("icloud 密码") || lower.contains("icloud passwords") var roles: [String: Int] = [:] for n in nodes { roles[n.role, default: 0] += 1 } - return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined)) + return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: diagnosticURLSummary ?? DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined), role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) + } + static func makeAccessibilityObservation(title: String?, role: String, subrole: String, isModal: Bool, isMain: Bool, isFocused: Bool, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { + makeAccessibilityObservation(title: title, nodes: nodes, role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) } static func makeWindowObservation(info: [String: Any], expectedPID: pid_t) -> DiagnosticWindow? { @@ -207,9 +220,23 @@ struct BrowserDiagnosticReport: Equatable, Sendable { let pid = app.processIdentifier.map(String.init) ?? "nil" lines.append("app=\(app.displayName) bundle=\(app.bundleIdentifier) pid=\(pid) activation=\(app.activationPolicy ?? "nil") permitted=\(app.permitted)") for w in app.windows.sorted(by: { $0.number < $1.number }) { lines.append("cgWindow=\(w.number) size=\(w.width)x\(w.height) title=\(DiagnosticRedactor.redactTitle(w.title)) match=\(w.titleMatches)") } - for ax in app.accessibilityWindows.sorted(by: { (DiagnosticRedactor.redactTitle($0.title), $0.extensionURLSummary ?? "") < (DiagnosticRedactor.redactTitle($1.title), $1.extensionURLSummary ?? "") }) { + func accessibilitySortKey(_ ax: DiagnosticAccessibilityWindow) -> [String] { + let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") + func bit(_ value: Bool) -> String { value ? "1" : "0" } + return [ + DiagnosticRedactor.redactTitle(ax.title), ax.extensionURLSummary ?? "", ax.role, ax.subrole, + bit(ax.isModal), bit(ax.isMain), bit(ax.isFocused), bit(ax.hasExtensionURL), bit(ax.hasPopupPath), + bit(ax.hasICloudIdentity), bit(ax.hasAutofillTerms), bit(ax.hasVerificationCodeTerms), roles, + bit(ax.stablePopupSignature), bit(ax.authorizationContextMatches) + ] + } + for ax in app.accessibilityWindows.sorted(by: { + let lhs = accessibilitySortKey($0), rhs = accessibilitySortKey($1) + for (a, b) in zip(lhs, rhs) where a != b { return a < b } + return false + }) { let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") - lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") } } return lines.joined(separator: "\n") diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 1f2bc59..2fcc895 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -178,11 +178,23 @@ final class BrowserDiagnosticsTests: XCTestCase { } func test_report_plain_text_is_deterministic_and_redacted() { - let ax = DiagnosticAccessibilityWindow(title: "AX 123456", extensionURLSummary: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42", hasExtensionURL: true, hasPopupPath: true, hasICloudIdentity: true, hasAutofillTerms: true, hasVerificationCodeTerms: true, roleCounts: ["AXButton": 2, "AXTextField": 1], stablePopupSignature: true, authorizationContextMatches: true) + let ax = BrowserDiagnostics.makeAccessibilityObservation(title: "AX 123456", role: "AXWindow", subrole: "AXDialog", isModal: true, isMain: false, isFocused: true, nodes: [DiagnosticNode(role: "AXWebArea", text: "RAW-PRIVATE-BODY", diagnosticURL: "chrome-extension://abcdefghijklmnop/page_popup.html?popupWindow=42"), DiagnosticNode(role: "AXButton", text: ""), DiagnosticNode(role: "AXButton", text: ""), DiagnosticNode(role: "AXTextField", text: "")]) let app = DiagnosticApplication(displayName: "Arc", bundleIdentifier: "company.thebrowser.Browser", processIdentifier: 42, activationPolicy: "regular", permitted: true, windows: [DiagnosticWindow(number: 7, width: 800, height: 600, title: "code 123456", titleMatches: true)], accessibilityWindows: [ax]) let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) let text = report.plainText XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) + XCTAssertTrue(text.contains("windowRole=AXWindow windowSubrole=AXDialog modal=true main=false focused=true")) + XCTAssertFalse(text.contains("RAW-PRIVATE-BODY")) + } + + func test_report_plain_text_sorts_accessibility_windows_by_all_rendered_fields() { + let first = DiagnosticAccessibilityWindow(title: "Same", extensionURLSummary: "chrome-extension://id/path", hasExtensionURL: true, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: true, hasVerificationCodeTerms: false, roleCounts: ["AXButton": 1], stablePopupSignature: false, authorizationContextMatches: true, role: "AXWindow", subrole: "AXDialog", isModal: true, isMain: false, isFocused: false) + let second = DiagnosticAccessibilityWindow(title: "Same", extensionURLSummary: "chrome-extension://id/path", hasExtensionURL: false, hasPopupPath: true, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: true, roleCounts: ["AXTextField": 2], stablePopupSignature: true, authorizationContextMatches: false, role: "AXWindow", subrole: "AXSheet", isModal: false, isMain: true, isFocused: true) + let app1 = application(windows: [visible], accessibilityWindows: [first, second]) + let app2 = application(windows: [visible], accessibilityWindows: [second, first]) + let report1 = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app1]) + let report2 = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app2]) + XCTAssertEqual(report1.plainText, report2.plainText) } func test_all_conclusions_have_chinese_summary() { @@ -208,9 +220,26 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertEqual(observation.roleCounts["AXStaticText"], 1) XCTAssertEqual(observation.roleCounts["AXTextField"], 1) XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) XCTAssertFalse(String(describing: observation).contains("secret-id")) } + func test_accessibility_observation_reports_window_structure_and_prefers_diagnostic_url() { + let observation = BrowserDiagnostics.makeAccessibilityObservation( + title: nil, role: "AXWindow", subrole: "AXDialog", isModal: true, isMain: false, isFocused: true, + nodes: [ + DiagnosticNode(role: "AXWebArea", text: "RAW-PRIVATE-BODY", diagnosticURL: "chrome-extension://secret-id/page_popup.html?popupWindow=42#token"), + DiagnosticNode(role: "AXTextField", text: "", diagnosticURL: nil) + ]) + XCTAssertEqual(observation.extensionURLSummary, "chrome-extension:///page_popup.html") + XCTAssertTrue(observation.hasExtensionURL) + XCTAssertTrue(observation.hasPopupPath) + XCTAssertEqual(observation.role, "AXWindow"); XCTAssertEqual(observation.subrole, "AXDialog") + XCTAssertTrue(observation.isModal); XCTAssertFalse(observation.isMain); XCTAssertTrue(observation.isFocused) + XCTAssertFalse(String(describing: observation).contains("secret-id")); XCTAssertFalse(String(describing: observation).contains("RAW-PRIVATE-BODY")) + } + func test_popup_signature_and_authorization_context_are_independent() { let popup = BrowserDiagnostics.makeAccessibilityObservation(title: "iCloud Passwords", nodes: [DiagnosticNode(role: "AXStaticText", text: "chrome-extension://id/page_popup.html")]) XCTAssertTrue(popup.stablePopupSignature) From 78b511da0ccec1c0575e69841ea321d4624425c8 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:19:15 +0800 Subject: [PATCH 20/23] docs: design origin-gated Arc autofill --- ...-08-04-arc-origin-gated-autofill-design.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md diff --git a/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md new file mode 100644 index 0000000..ff0dc2d --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md @@ -0,0 +1,63 @@ +# Arc Origin-Gated Autofill Design + +## Goal + +Allow Arc's untitled iCloud Passwords extension popup to reach the existing fill path without trusting a window title, window size, Arc-specific rule, or page-controlled text alone. + +## Evidence + +The live Arc report identified an AX window with all of the following signals: + +- `AXURL` normalized as `chrome-extension:///page_popup.html`; +- iCloud identity, autofill, and verification-code context; +- six `AXTextField` elements; +- no CGWindow or AX window title. + +The current production gate rejects this window before AX inspection because its Core Graphics title is empty. + +## Chosen Design + +Keep the existing Core Graphics title match as the preferred fast path. For each permitted running browser process that has no titled candidate, select one fallback candidate from its topmost on-screen Core Graphics window. This is only a scheduling identity for retry and handled-window bookkeeping; it is not a trust signal. + +The fallback candidate receives the same read-only AX traversal as a titled candidate, but it may return a fill target only if a single AX window satisfies every condition below: + +1. an AX attribute URL, not text scraping, has either the `chrome-extension` or `moz-extension` scheme and a `/page_popup.html` path; +2. existing authorization context recognizes an iCloud/extension identity plus autofill and verification-code terms; +3. the AX window exposes at least six supported text inputs. + +The selected AX window is still the one raised and filled by the existing code. The Core Graphics fallback identity is never used to infer extension origin or select fields. + +## Candidate and Retry Behavior + +Candidate discovery reads Core Graphics windows once per scan. For each eligible PID, it selects the first title-matching window in window-list order; if none matches, it selects that PID's first visible window in the same order and marks it as origin-gated. At most one candidate is emitted per PID. + +Existing `WindowIdentity`, retry backoff, and handled-window storage remain unchanged. Because the fallback identity follows the topmost window, a newly shown popup obtains a new identity and is eligible for an immediate AX check; an unchanged browser window remains governed by the existing retry backoff. No new timer, AX observer, window-size threshold, or private API is introduced. + +## Security Boundary + +The legacy title-matched path retains its current behavior. The new titleless path is stricter: it requires an AXURL-derived extension popup origin, full existing authorization context, and six supported inputs. A window title, empty title, dimensions, visible strings, bundle ID, or role alone cannot authorize automatic filling. + +The implementation must keep raw extension URLs transient. It may use them to test scheme/path in the current AX traversal, but it must not persist raw host, query, fragment, extension identifier, raw AX text, or one-time code in reports, state, logs, or clipboard. + +## Diagnostic Semantics + +After the production predicate exists, the diagnostic evaluator should report `target_recognized` for a permitted application's AX window with trusted extension URL, context, and supported inputs even if every CG title mismatches. `window_title_mismatch` remains the result only when no trusted AX target is present. + +## Tests and Manual Validation + +Automated tests must prove: + +- candidate discovery emits one origin-gated fallback per eligible PID when no titles match and retains title preference when one does; +- a fallback target requires AXURL origin, context, and six inputs; each missing component is rejected; +- title-matched legacy candidates preserve their current acceptance behavior; +- a trusted AX target overrides a title mismatch in diagnostic evaluation; +- fallback candidates keep current retry identity semantics and no production path accepts a size-only or text-only popup. + +Manual validation repeats the captured Arc scenario. Expected results are a `target_recognized` diagnostic and a fill attempt only after the strong origin predicate is present. Recheck Firefox, manual fill, automatic scan, application rules, and permissions after the change. + +## Non-Goals + +- Trusting compact windows, untitled windows, or Arc-specific bundle IDs. +- Removing the legacy title-matched fast path. +- Using private AX-to-CG mapping APIs. +- Changing code capture, keyboard-event generation, fill timing, or permission handling. From c8f41bc2958cb90d2ff3a5a5bbf888e79046f218 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:22:44 +0800 Subject: [PATCH 21/23] docs: plan origin-gated Arc autofill --- .../2026-08-04-arc-origin-gated-autofill.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md diff --git a/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md new file mode 100644 index 0000000..bf5fc0f --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md @@ -0,0 +1,341 @@ +# Arc Origin-Gated Autofill 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:** Allow Arc's untitled iCloud Passwords extension popup to use the existing fill path only after a trusted AXURL origin, authorization context, and six input fields are verified. + +**Architecture:** Candidate discovery emits one candidate per permitted browser PID: a title-matched window when present, otherwise the PID's topmost visible window marked origin-gated. Title candidates preserve legacy target matching; origin-gated candidates may succeed only with an AX-attribute `chrome-extension`/`moz-extension` popup URL, existing context, and six inputs. Diagnostics explicitly record whether an extension URL came from AX attributes. + +**Tech Stack:** Swift 5.10, AppKit/ApplicationServices/CoreGraphics, XCTest, SwiftPM. + +--- + +### Task 0: Commit the already-verified origin diagnostic collector + +**Files:** +- Modify already present: `Sources/ApplePasswordBridge/Accessibility.swift` +- Modify already present: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift` +- Modify already present: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Confirm the existing verification evidence** + +Run: + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/build +``` + +Expected: 36 tests pass. The uncommitted change is limited to URL/boolean normalization, read-only AX metadata, live diagnostic wiring, and its tests. + +- [ ] **Step 2: Commit the verified collector without mixing production changes** + +```bash +git add Sources/ApplePasswordBridge/Accessibility.swift \ + Sources/ApplePasswordBridge/BrowserDiagnostics.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: collect Arc authorization origin evidence" +``` + +### Task 1: Add pure candidate selection and origin helpers + +**Files:** +- Modify: `Sources/ApplePasswordBridge/FirefoxAutofill.swift:26-75` +- Modify: `Sources/ApplePasswordBridge/CodeParser.swift:26-48` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing pure selection and URL-origin tests** + +Add tests around a new internal helper that selects a single `WindowIdentity` plus `requiresTrustedOrigin` per eligible PID from CG dictionaries: + +```swift +func test_candidate_selection_prefers_title_then_topmost_fallback_once_per_pid() { + let pid = pid_t(42) + let windows: [[String: Any]] = [ + [kCGWindowOwnerPID as String: NSNumber(value: pid), + kCGWindowNumber as String: NSNumber(value: UInt32(9)), + kCGWindowName as String: ""], + [kCGWindowOwnerPID as String: NSNumber(value: pid), + kCGWindowNumber as String: NSNumber(value: UInt32(10)), + kCGWindowName as String: "iCloud Passwords"] + ] + + let selected = BrowserAutofill.selectCandidates( + windowInfo: windows, + eligiblePIDs: [pid] + ) + + XCTAssertEqual(selected.count, 1) + XCTAssertEqual(selected[0].identity.windowNumber, 10) + XCTAssertFalse(selected[0].requiresTrustedOrigin) +} + +func test_candidate_selection_uses_topmost_window_as_origin_gated_fallback() { + let pid = pid_t(42) + let selected = BrowserAutofill.selectCandidates( + windowInfo: [[ + kCGWindowOwnerPID as String: NSNumber(value: pid), + kCGWindowNumber as String: NSNumber(value: UInt32(9)), + kCGWindowName as String: "" + ]], + eligiblePIDs: [pid] + ) + + XCTAssertEqual(selected.map(\.identity.windowNumber), [9]) + XCTAssertEqual(selected.map(\.requiresTrustedOrigin), [true]) +} + +func test_extension_popup_origin_requires_extension_scheme_and_popup_path() { + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL( + "chrome-extension://id/page_popup.html?popupWindow=42" + )) + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL( + "moz-extension://id/page_popup.html" + )) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL( + "https://example.com/page_popup.html" + )) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL( + "chrome-extension://id/other.html" + )) +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +Run the full SwiftPM command above with `--filter BrowserDiagnosticsTests/test_candidate_selection_prefers_title_then_topmost_fallback_once_per_pid` and then the URL-origin test. + +Expected: compilation fails because `selectCandidates` and `isBrowserExtensionPopupURL` do not yet exist. + +- [ ] **Step 3: Implement pure helpers** + +Add a `CandidateDescriptor` nested in `BrowserAutofill`: + +```swift +struct CandidateDescriptor: Equatable { + let identity: WindowIdentity + let requiresTrustedOrigin: Bool +} +``` + +Implement `selectCandidates(windowInfo:eligiblePIDs:)` by scanning the supplied list once. Ignore entries without numeric PID/window number or a PID outside `eligiblePIDs`. Record the first window for each PID as fallback and the first title-matching window as preferred; after the scan return title-matching descriptor when available, otherwise fallback marked `requiresTrustedOrigin: true`. Sort returned descriptors by their first appearance index, and emit no more than one per PID. + +Add the pure authorization helper: + +```swift +static func isBrowserExtensionPopupURL(_ url: String) -> Bool { + let lower = url.lowercased() + let isExtension = lower.hasPrefix("chrome-extension://") + || lower.hasPrefix("moz-extension://") + return isExtension && lower.contains("/page_popup.html") +} +``` + +- [ ] **Step 4: Verify GREEN and commit** + +Run the focused tests and the complete suite. Expected: all tests pass. + +```bash +git add Sources/ApplePasswordBridge/FirefoxAutofill.swift \ + Sources/ApplePasswordBridge/CodeParser.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: select origin-gated browser candidates" +``` + +### Task 2: Gate fallback fill targets on AXURL origin, context, and six inputs + +**Files:** +- Modify: `Sources/ApplePasswordBridge/FirefoxAutofill.swift:32-125` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing target-predicate tests** + +Extract the candidate-dependent AX decision into an internal pure helper accepting `requiresTrustedOrigin`, node URLs, context result, and field count. Add tests: + +```swift +func test_origin_gated_target_requires_url_context_and_six_fields() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 5 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["https://example.com/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) +} + +func test_title_candidate_retains_legacy_target_acceptance() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: false, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 1 + )) +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +Run the focused SwiftPM test command for the first test. Expected: compilation fails because `acceptsTarget` does not exist. + +- [ ] **Step 3: Carry origin-gated state into real candidate discovery** + +Extend `Candidate`: + +```swift +let requiresTrustedOrigin: Bool +``` + +Rewrite `authorizationWindowCandidates(policy:)` to build `eligibleApplicationsByPID`, call `selectCandidates(windowInfo:eligiblePIDs:)`, and map descriptors to applications. Preserve exactly one candidate per eligible PID. Do not activate or focus applications in this method. + +- [ ] **Step 4: Implement the pure target gate and use it in locateTarget** + +Implement: + +```swift +static func acceptsTarget( + requiresTrustedOrigin: Bool, + diagnosticURLs: [String], + hasAuthorizationContext: Bool, + hasStablePopupSignature: Bool, + inputCount: Int +) -> Bool { + if requiresTrustedOrigin { + return diagnosticURLs.contains(AuthorizationContext.isBrowserExtensionPopupURL) + && hasAuthorizationContext + && inputCount >= 6 + } + return (hasStablePopupSignature || hasAuthorizationContext) && inputCount > 0 +} +``` + +In `locateTarget`, collect `let diagnosticURLs = nodes.compactMap(\.diagnosticURL)`, calculate the existing context/signature and `fields`, then use `acceptsTarget` with `applicationCandidates[0].requiresTrustedOrigin`. Preserve the existing `inputNotFound` error only after a recognized legacy or trusted-origin context has no inputs. Do not persist or render `diagnosticURLs`. + +- [ ] **Step 5: Verify GREEN and commit** + +Run the focused tests and complete suite. Expected: all tests pass and existing Firefox tests remain green. + +```bash +git add Sources/ApplePasswordBridge/FirefoxAutofill.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: gate untitled popup autofill on extension origin" +``` + +### Task 3: Surface trusted AX origin in diagnostics and adjust conclusion precedence + +**Files:** +- Modify: `Sources/ApplePasswordBridge/BrowserDiagnostics.swift:74-159,225-250` +- Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` + +- [ ] **Step 1: Write failing evaluator/report tests** + +Construct a permitted running application with a mismatched CG title and one AX window containing `hasAccessibilityExtensionURL: true`, `hasPopupPath: true`, `authorizationContextMatches: true`, and six fields. Assert `BrowserDiagnostics.evaluate` is `.targetRecognized`. Construct the same window with `hasAccessibilityExtensionURL: false` and assert `.windowTitleMismatch`. + +Add a report assertion: + +```swift +XCTAssertTrue(report.plainText.contains("axURL=true")) +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run the focused evaluator test. Expected: the current evaluator returns `.windowTitleMismatch` and `hasAccessibilityExtensionURL` is unavailable. + +- [ ] **Step 3: Add source-provenance boolean** + +Add `hasAccessibilityExtensionURL` to `DiagnosticAccessibilityWindow`, defaulting to `false` at the end of its initializer. In `makeAccessibilityObservation`, set it only when a raw `DiagnosticNode.diagnosticURL` itself passes `DiagnosticRedactor.extensionURLSummary`; text-discovered URLs must leave it false. Render it as `axURL=true` or `axURL=false` in the deterministic AX report line and include it in the AX sort key. + +- [ ] **Step 4: Prioritize trusted AX targets in evaluate** + +Before the CG title mismatch guard, compute trusted AX windows: + +```swift +let trusted = permitted.flatMap(\.accessibilityWindows).filter { + $0.hasAccessibilityExtensionURL + && $0.hasPopupPath + && $0.authorizationContextMatches +} +if trusted.contains(where: { $0.supportedInputCount >= 6 }) { + return .targetRecognized +} +``` + +Keep all other current failure boundaries unchanged. A text-only `chrome-extension://` match cannot bypass title mismatch because its provenance boolean is false. + +- [ ] **Step 5: Verify GREEN and commit** + +Run focused evaluator/report tests and the full suite. Expected: all tests pass. + +```bash +git add Sources/ApplePasswordBridge/BrowserDiagnostics.swift \ + Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +git commit -m "feat: recognize trusted untitled extension popups" +``` + +### Task 4: Clean verification and Arc validation + +**Files:** +- Create ignored artifact: `dist/Password-Bridge-Arc-Origin-Gated.zip` + +- [ ] **Step 1: Fresh test and release build** + +```bash +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/swiftpm \ +swift test --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/gated-build + +env CLANG_MODULE_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_MODULECACHE_OVERRIDE=/tmp/apple-password-bridge-arc/gated-cache/clang \ +SWIFTPM_CACHE_PATH=/tmp/apple-password-bridge-arc/gated-cache/swiftpm \ +swift build -c release --disable-sandbox --scratch-path /tmp/apple-password-bridge-arc/gated-build +``` + +Expected: complete suite has zero failures and release build exits zero. + +- [ ] **Step 2: Package and strictly verify** + +```bash +make app +gated_package_dir=$(mktemp -d /private/tmp/apple-password-bridge-gated.XXXXXX) +ditto --norsrc --noextattr --noqtn --noacl \ + "dist/Password Bridge.app" "$gated_package_dir/Password Bridge.app" +codesign --verify --deep --strict "$gated_package_dir/Password Bridge.app" +ditto -c -k --norsrc --noextattr --noqtn --noacl --keepParent \ + "$gated_package_dir/Password Bridge.app" \ + "dist/Password-Bridge-Arc-Origin-Gated.zip" +gated_verify_dir=$(mktemp -d /private/tmp/apple-password-bridge-gated-verify.XXXXXX) +ditto -x -k "dist/Password-Bridge-Arc-Origin-Gated.zip" "$gated_verify_dir" +codesign --verify --deep --strict "$gated_verify_dir/Password Bridge.app" +``` + +Expected: both signature checks exit zero. + +- [ ] **Step 3: Manual Arc and regression validation** + +Launch the verified app, open the same Arc authorization popup, and run diagnostics. Expected report: `conclusion=target_recognized`, `axURL=true`, `extension=chrome-extension:///page_popup.html`, and six text fields. Then use manual fill with a fresh code and confirm it fills only the verified Arc popup. + +With Firefox open, confirm title-matched filling still works. With a normal browser page containing similar words and inputs but no extension AXURL, confirm diagnostics do not show a trusted target and automatic fill does not begin. From ecfa4d46eae3d027de577766ef6acc362e34806a Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:23:24 +0800 Subject: [PATCH 22/23] feat: collect Arc authorization origin evidence --- Sources/ApplePasswordBridge/Accessibility.swift | 16 ++++++++++++++++ .../ApplePasswordBridge/BrowserDiagnostics.swift | 12 ++++++++++-- .../BrowserDiagnosticsTests.swift | 7 +++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/ApplePasswordBridge/Accessibility.swift b/Sources/ApplePasswordBridge/Accessibility.swift index 675d5ea..5fe9601 100644 --- a/Sources/ApplePasswordBridge/Accessibility.swift +++ b/Sources/ApplePasswordBridge/Accessibility.swift @@ -8,6 +8,10 @@ enum AccessibilityValueNormalizer { if let url = value as? NSURL { return url.absoluteString } return nil } + + static func bool(from value: Any?) -> Bool? { + (value as? NSNumber)?.boolValue + } } struct AccessibilityNode { @@ -94,6 +98,18 @@ enum AccessibilityTree { copy(element, attribute: kAXTitleAttribute as CFString) as? String } + static func role(of element: AXUIElement) -> String { + (copy(element, attribute: kAXRoleAttribute as CFString) as? String) ?? "" + } + + static func subrole(of element: AXUIElement) -> String { + (copy(element, attribute: kAXSubroleAttribute as CFString) as? String) ?? "" + } + + static func bool(_ element: AXUIElement, attribute: CFString) -> Bool { + AccessibilityValueNormalizer.bool(from: copy(element, attribute: attribute)) ?? false + } + static func focus(_ element: AXUIElement) -> Bool { AXUIElementSetAttributeValue( element, diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index f7f9de9..465c6d1 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -196,8 +196,16 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { let ax = AXUIElementCreateApplication(app.processIdentifier) for w in AccessibilityTree.windows(of: ax) { if Task.isCancelled { break } - let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text) } - axWindows.append(Self.makeAccessibilityObservation(title: AccessibilityTree.title(of: w), nodes: nodes)) + let nodes = AccessibilityTree.collect(from: w, maxDepth: 12, maxNodes: 800).map { DiagnosticNode(role: $0.role, text: $0.text, diagnosticURL: $0.diagnosticURL) } + axWindows.append(Self.makeAccessibilityObservation( + title: AccessibilityTree.title(of: w), + role: AccessibilityTree.role(of: w), + subrole: AccessibilityTree.subrole(of: w), + isModal: AccessibilityTree.bool(w, attribute: kAXModalAttribute as CFString), + isMain: AccessibilityTree.bool(w, attribute: kAXMainAttribute as CFString), + isFocused: AccessibilityTree.bool(w, attribute: kAXFocusedAttribute as CFString), + nodes: nodes + )) } } let activation: String? = app.map { switch $0.activationPolicy { case .regular: return "regular"; case .accessory: return "accessory"; case .prohibited: return "prohibited"; default: return "unknown" } } diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index 2fcc895..cc40f8f 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -44,6 +44,13 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertNil(AccessibilityValueNormalizer.urlString(from: NSNumber(value: 42))) } + func test_accessibility_boolean_normalizer_accepts_cfboolean_and_nsnumber() { + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanTrue), true) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: kCFBooleanFalse), false) + XCTAssertEqual(AccessibilityValueNormalizer.bool(from: NSNumber(value: true)), true) + XCTAssertNil(AccessibilityValueNormalizer.bool(from: "true")) + } + @MainActor func test_bridge_model_runs_diagnostics_and_replaces_report() async { let first = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 1), ruleMode: .allowlist, applications: []) From d5f387b695db25d8ee4cb24b024a9dcbf6cdd102 Mon Sep 17 00:00:00 2001 From: sky <35763860+hellosky0@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:43:04 +0800 Subject: [PATCH 23/23] feat: gate untitled popup autofill on extension origin --- Sources/ApplePasswordBridge/BridgeModel.swift | 8 +- .../BrowserDiagnostics.swift | 20 ++- Sources/ApplePasswordBridge/CodeParser.swift | 9 ++ .../ApplePasswordBridge/FirefoxAutofill.swift | 75 +++++++--- .../BrowserDiagnosticsTests.swift | 130 ++++++++++++++++++ .../2026-08-04-arc-origin-gated-autofill.md | 26 ++-- ...-08-04-arc-origin-gated-autofill-design.md | 10 +- 7 files changed, 234 insertions(+), 44 deletions(-) diff --git a/Sources/ApplePasswordBridge/BridgeModel.swift b/Sources/ApplePasswordBridge/BridgeModel.swift index bc709b4..6ae87ec 100644 --- a/Sources/ApplePasswordBridge/BridgeModel.swift +++ b/Sources/ApplePasswordBridge/BridgeModel.swift @@ -303,7 +303,7 @@ final class BridgeModel: ObservableObject { } guard let code = currentCode, code.isFresh() else { currentCode = nil - scheduleRetry(for: [target.identity]) + scheduleRetry(for: [target.retryIdentity]) if manual { statusText = "未发现有效的 Apple 密码验证码窗口" } return } @@ -311,7 +311,7 @@ final class BridgeModel: ObservableObject { $0.value == code.value && $0.isFresh(at: now) } ?? false guard manual || !isRecentlyFilledCode else { - markHandled(target.identity) + markHandled(target.retryIdentity) return } @@ -324,10 +324,10 @@ final class BridgeModel: ObservableObject { source: code.source ) currentCode = nil - markHandled(target.identity) + markHandled(target.retryIdentity) statusText = "已填入 \(target.application.localizedName ?? "目标应用")(\(code.source.rawValue))" } catch { - scheduleRetry(for: [target.identity]) + scheduleRetry(for: [target.retryIdentity]) statusText = error.localizedDescription } } diff --git a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift index 465c6d1..41004f5 100644 --- a/Sources/ApplePasswordBridge/BrowserDiagnostics.swift +++ b/Sources/ApplePasswordBridge/BrowserDiagnostics.swift @@ -80,6 +80,8 @@ public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let isFocused: Bool public let extensionURLSummary: String? public let hasExtensionURL: Bool + public let hasAccessibilityExtensionURL: Bool + public let hasAccessibilityPopupURL: Bool public let hasPopupPath: Bool public let hasICloudIdentity: Bool public let hasAutofillTerms: Bool @@ -87,11 +89,13 @@ public struct DiagnosticAccessibilityWindow: Equatable, Sendable { public let roleCounts: [String: Int] public let stablePopupSignature: Bool public let authorizationContextMatches: Bool - public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool, role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) { + public init(title: String, extensionURLSummary: String?, hasExtensionURL: Bool, hasAccessibilityExtensionURL: Bool = false, hasAccessibilityPopupURL: Bool = false, hasPopupPath: Bool, hasICloudIdentity: Bool, hasAutofillTerms: Bool, hasVerificationCodeTerms: Bool, roleCounts: [String: Int], stablePopupSignature: Bool, authorizationContextMatches: Bool, role: String = "", subrole: String = "", isModal: Bool = false, isMain: Bool = false, isFocused: Bool = false) { self.title = DiagnosticRedactor.redactTitle(title) self.role = role; self.subrole = subrole; self.isModal = isModal; self.isMain = isMain; self.isFocused = isFocused self.extensionURLSummary = extensionURLSummary.flatMap { DiagnosticRedactor.extensionURLSummary(from: $0) } self.hasExtensionURL = hasExtensionURL + self.hasAccessibilityExtensionURL = hasAccessibilityExtensionURL + self.hasAccessibilityPopupURL = hasAccessibilityPopupURL self.hasPopupPath = hasPopupPath self.hasICloudIdentity = hasICloudIdentity self.hasAutofillTerms = hasAutofillTerms @@ -134,6 +138,12 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { guard running.contains(where: { $0.permitted }) else { return .applicationRejectedByPolicy } let permitted = running.filter { $0.permitted } guard permitted.contains(where: { !$0.windows.isEmpty }) else { return .noVisibleWindows } + let trustedUntitledTargets = permitted.flatMap(\.accessibilityWindows).contains { + $0.hasAccessibilityPopupURL + && $0.authorizationContextMatches + && $0.supportedInputCount >= 6 + } + if trustedUntitledTargets { return .targetRecognized } guard permitted.contains(where: { $0.windows.contains { $0.titleMatches } }) else { return .windowTitleMismatch } guard permitted.contains(where: { !$0.accessibilityWindows.isEmpty }) else { return .accessibilityWindowsUnavailable } let matching = permitted.flatMap { $0.accessibilityWindows }.filter { $0.stablePopupSignature || $0.authorizationContextMatches } @@ -148,12 +158,14 @@ public struct BrowserDiagnostics: BrowserDiagnosticsRunning, Sendable { let lower = all.lowercased() let rawDiagnosticURLs = nodes.compactMap(\.diagnosticURL) let diagnosticURLSummary = rawDiagnosticURLs.compactMap { DiagnosticRedactor.extensionURLSummary(from: $0) }.first + let hasAccessibilityExtensionURL = diagnosticURLSummary != nil + let hasAccessibilityPopupURL = rawDiagnosticURLs.contains(where: AuthorizationContext.isBrowserExtensionPopupURL) let hasURL = diagnosticURLSummary != nil || lower.contains("chrome-extension://") || lower.contains("moz-extension://") let popup = hasURL && (lower.contains("/page_popup.html") || rawDiagnosticURLs.contains { $0.lowercased().contains("/page_popup.html") }) let icloud = AuthorizationContext.isICloudPasswordWindowTitle(title) || lower.contains("icloud 密码") || lower.contains("icloud passwords") var roles: [String: Int] = [:] for n in nodes { roles[n.role, default: 0] += 1 } - return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: diagnosticURLSummary ?? DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined), role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) + return DiagnosticAccessibilityWindow(title: title ?? "", extensionURLSummary: diagnosticURLSummary ?? DiagnosticRedactor.extensionURLSummary(from: combined), hasExtensionURL: hasURL, hasAccessibilityExtensionURL: hasAccessibilityExtensionURL, hasAccessibilityPopupURL: hasAccessibilityPopupURL, hasPopupPath: popup, hasICloudIdentity: icloud, hasAutofillTerms: AuthorizationContext.hasAutofillTerms(all), hasVerificationCodeTerms: AuthorizationContext.hasVerificationCodeTerms(all), roleCounts: roles, stablePopupSignature: AuthorizationContext.isBrowserExtensionPopup(title: title, text: combined), authorizationContextMatches: AuthorizationContext.isBrowserExtensionAuthorization(title: title, text: combined), role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) } static func makeAccessibilityObservation(title: String?, role: String, subrole: String, isModal: Bool, isMain: Bool, isFocused: Bool, nodes: [DiagnosticNode]) -> DiagnosticAccessibilityWindow { makeAccessibilityObservation(title: title, nodes: nodes, role: role, subrole: subrole, isModal: isModal, isMain: isMain, isFocused: isFocused) @@ -233,7 +245,7 @@ struct BrowserDiagnosticReport: Equatable, Sendable { func bit(_ value: Bool) -> String { value ? "1" : "0" } return [ DiagnosticRedactor.redactTitle(ax.title), ax.extensionURLSummary ?? "", ax.role, ax.subrole, - bit(ax.isModal), bit(ax.isMain), bit(ax.isFocused), bit(ax.hasExtensionURL), bit(ax.hasPopupPath), + bit(ax.isModal), bit(ax.isMain), bit(ax.isFocused), bit(ax.hasExtensionURL), bit(ax.hasAccessibilityExtensionURL), bit(ax.hasAccessibilityPopupURL), bit(ax.hasPopupPath), bit(ax.hasICloudIdentity), bit(ax.hasAutofillTerms), bit(ax.hasVerificationCodeTerms), roles, bit(ax.stablePopupSignature), bit(ax.authorizationContextMatches) ] @@ -244,7 +256,7 @@ struct BrowserDiagnosticReport: Equatable, Sendable { return false }) { let roles = ax.roleCounts.keys.sorted().map { "\($0)=\(ax.roleCounts[$0]!)" }.joined(separator: ", ") - lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") + lines.append("axTitle=\(DiagnosticRedactor.redactTitle(ax.title)) extension=\(ax.extensionURLSummary ?? "nil") axURL=\(ax.hasAccessibilityExtensionURL) axPopupURL=\(ax.hasAccessibilityPopupURL) flags=\(ax.hasExtensionURL),\(ax.hasPopupPath),\(ax.hasICloudIdentity),\(ax.hasAutofillTerms),\(ax.hasVerificationCodeTerms) roles=\(roles) windowRole=\(ax.role) windowSubrole=\(ax.subrole) modal=\(ax.isModal) main=\(ax.isMain) focused=\(ax.isFocused) existingMatch=\(ax.stablePopupSignature || ax.authorizationContextMatches)") } } return lines.joined(separator: "\n") diff --git a/Sources/ApplePasswordBridge/CodeParser.swift b/Sources/ApplePasswordBridge/CodeParser.swift index 60ec688..ef85252 100644 --- a/Sources/ApplePasswordBridge/CodeParser.swift +++ b/Sources/ApplePasswordBridge/CodeParser.swift @@ -46,6 +46,15 @@ enum AuthorizationContext { return normalized.contains("icloud") && (normalized.contains("密码") || normalized.contains("password")) } + + static func isBrowserExtensionPopupURL(_ url: String) -> Bool { + guard let components = URLComponents(string: url), + let scheme = components.scheme?.lowercased(), + (scheme == "chrome-extension" || scheme == "moz-extension"), + components.host != nil, + components.path == "/page_popup.html" else { return false } + return true + } } enum VerificationCodeParser { diff --git a/Sources/ApplePasswordBridge/FirefoxAutofill.swift b/Sources/ApplePasswordBridge/FirefoxAutofill.swift index 96a60ad..4042551 100644 --- a/Sources/ApplePasswordBridge/FirefoxAutofill.swift +++ b/Sources/ApplePasswordBridge/FirefoxAutofill.swift @@ -24,18 +24,39 @@ enum AutofillFailure: LocalizedError { } final class BrowserAutofill { + static func acceptsTarget( + requiresTrustedOrigin: Bool, + diagnosticURLs: [String], + hasAuthorizationContext: Bool, + hasStablePopupSignature: Bool, + inputCount: Int + ) -> Bool { + if requiresTrustedOrigin { + return diagnosticURLs.contains(where: AuthorizationContext.isBrowserExtensionPopupURL) + && hasAuthorizationContext + && inputCount >= 6 + } + return (hasStablePopupSignature || hasAuthorizationContext) && inputCount > 0 + } + struct WindowIdentity: Hashable { let processIdentifier: pid_t let windowNumber: CGWindowID } + struct CandidateDescriptor: Equatable { + let identity: WindowIdentity + let requiresTrustedOrigin: Bool + } + struct Candidate { let identity: WindowIdentity let application: NSRunningApplication + let requiresTrustedOrigin: Bool } struct Target { - let identity: WindowIdentity + let retryIdentity: WindowIdentity let application: NSRunningApplication let window: AXUIElement let fields: [AccessibilityNode] @@ -50,28 +71,30 @@ final class BrowserAutofill { kCGNullWindowID ) as? [[String: Any]] ?? [] - var candidates: [Candidate] = [] - var seen = Set() - for info in windowInfo { - let title = info[kCGWindowName as String] as? String - guard AuthorizationContext.isICloudPasswordWindowTitle(title), - let rawPID = info[kCGWindowOwnerPID as String] as? NSNumber, - let rawWindowNumber = info[kCGWindowNumber as String] as? NSNumber else { - continue + return Self.selectCandidates(windowInfo: windowInfo, eligiblePIDs: Set(applications.keys)) + .compactMap { descriptor in + guard let application = applications[descriptor.identity.processIdentifier] else { return nil } + return Candidate(identity: descriptor.identity, application: application, requiresTrustedOrigin: descriptor.requiresTrustedOrigin) } - let identity = WindowIdentity( - processIdentifier: rawPID.int32Value, - windowNumber: CGWindowID(rawWindowNumber.uint32Value) - ) - guard !seen.contains(identity), - let application = applications[identity.processIdentifier] else { - continue + } + + static func selectCandidates(windowInfo: [[String: Any]], eligiblePIDs: Set) -> [CandidateDescriptor] { + struct Entry { let descriptor: CandidateDescriptor; let index: Int } + var selected: [pid_t: Entry] = [:] + for (index, info) in windowInfo.enumerated() { + guard let rawPID = info[kCGWindowOwnerPID as String] as? NSNumber, + let rawWindowNumber = info[kCGWindowNumber as String] as? NSNumber else { continue } + let pid = rawPID.int32Value + guard eligiblePIDs.contains(pid) else { continue } + let identity = WindowIdentity(processIdentifier: pid, windowNumber: CGWindowID(rawWindowNumber.uint32Value)) + let trusted = AuthorizationContext.isICloudPasswordWindowTitle(info[kCGWindowName as String] as? String) + if let existing = selected[pid] { + if trusted && existing.descriptor.requiresTrustedOrigin { selected[pid] = Entry(descriptor: CandidateDescriptor(identity: identity, requiresTrustedOrigin: false), index: index) } + } else { + selected[pid] = Entry(descriptor: CandidateDescriptor(identity: identity, requiresTrustedOrigin: !trusted), index: index) } - seen.insert(identity) - candidates.append(Candidate(identity: identity, application: application)) - if candidates.count == 4 { break } } - return candidates + return selected.values.sorted { $0.index < $1.index }.map(\.descriptor) } func prepareAccessibility(for candidates: [Candidate]) { @@ -112,10 +135,18 @@ final class BrowserAutofill { title: title, text: text ) - guard hasStablePopupSignature || hasAuthorizationContext else { continue } + let diagnosticURLs = nodes.compactMap(\.diagnosticURL) + guard Self.acceptsTarget( + requiresTrustedOrigin: applicationCandidates[0].requiresTrustedOrigin, + diagnosticURLs: diagnosticURLs, + hasAuthorizationContext: hasAuthorizationContext, + hasStablePopupSignature: hasStablePopupSignature, + inputCount: fields.count + ) else { continue } guard !fields.isEmpty else { throw AutofillFailure.inputNotFound } + // The retry key comes from CG scheduling; `window` is this verified AX window. return Target( - identity: applicationCandidates[0].identity, + retryIdentity: applicationCandidates[0].identity, application: application, window: window, fields: fields diff --git a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift index cc40f8f..525de90 100644 --- a/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift +++ b/Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift @@ -146,6 +146,72 @@ final class BrowserDiagnosticsTests: XCTestCase { ) } + func test_trusted_accessibility_origin_overrides_mismatched_window_title() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + let trusted = DiagnosticAccessibilityWindow( + title: "", + extensionURLSummary: "chrome-extension://id/page_popup.html", + hasExtensionURL: true, + hasAccessibilityExtensionURL: true, + hasAccessibilityPopupURL: true, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: ["AXTextField": 6], + stablePopupSignature: false, + authorizationContextMatches: true + ) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [trusted])]), + .targetRecognized + ) + } + + func test_text_discovered_extension_url_does_not_override_mismatched_window_title() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + let textOnly = DiagnosticAccessibilityWindow( + title: "", + extensionURLSummary: "chrome-extension://id/page_popup.html", + hasExtensionURL: true, + hasAccessibilityExtensionURL: false, + hasAccessibilityPopupURL: false, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: ["AXTextField": 6], + stablePopupSignature: false, + authorizationContextMatches: true + ) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [textOnly])]), + .windowTitleMismatch + ) + } + + func test_accessibility_extension_url_without_popup_path_does_not_override_title_mismatch() { + let mismatched = DiagnosticWindow(number: 1, width: 800, height: 600, title: "Other", titleMatches: false) + let mixedEvidence = DiagnosticAccessibilityWindow( + title: "", + extensionURLSummary: "chrome-extension://id/other.html", + hasExtensionURL: true, + hasAccessibilityExtensionURL: true, + hasAccessibilityPopupURL: false, + hasPopupPath: true, + hasICloudIdentity: true, + hasAutofillTerms: true, + hasVerificationCodeTerms: true, + roleCounts: ["AXTextField": 6], + stablePopupSignature: false, + authorizationContextMatches: true + ) + XCTAssertEqual( + BrowserDiagnostics.evaluate([application(windows: [mismatched], accessibilityWindows: [mixedEvidence])]), + .windowTitleMismatch + ) + } + func test_matching_accessibility_window_is_not_shadowed_by_first_mismatch() { let mismatch = DiagnosticAccessibilityWindow(title: "Other", extensionURLSummary: nil, hasExtensionURL: false, hasPopupPath: false, hasICloudIdentity: false, hasAutofillTerms: false, hasVerificationCodeTerms: false, roleCounts: [:], stablePopupSignature: false, authorizationContextMatches: false) XCTAssertEqual( @@ -190,6 +256,7 @@ final class BrowserDiagnosticsTests: XCTestCase { let report = BrowserDiagnosticReport(generatedAt: Date(timeIntervalSince1970: 0), ruleMode: .allowlist, applications: [app]) let text = report.plainText XCTAssertTrue(text.contains("")); XCTAssertFalse(text.contains("123456")); XCTAssertFalse(text.contains("abcdefghijklmnop")); XCTAssertFalse(text.contains("popupWindow=42")); XCTAssertTrue(text.contains("AXButton=2, AXTextField=1")) + XCTAssertTrue(text.contains("axURL=true")) XCTAssertTrue(text.contains("windowRole=AXWindow windowSubrole=AXDialog modal=true main=false focused=true")) XCTAssertFalse(text.contains("RAW-PRIVATE-BODY")) } @@ -293,4 +360,67 @@ final class BrowserDiagnosticsTests: XCTestCase { XCTAssertTrue(AuthorizationContext.isApplePasswordAuthorization("自动填充 verification code")) XCTAssertFalse(AuthorizationContext.isApplePasswordAuthorization("自动填充 only")) } + + func test_origin_gated_candidate_selection_prefers_titled_window() { + let windows: [[String: Any]] = [ + [kCGWindowOwnerPID as String: NSNumber(value: 42), kCGWindowNumber as String: NSNumber(value: 9), kCGWindowName as String: ""], + [kCGWindowOwnerPID as String: NSNumber(value: 42), kCGWindowNumber as String: NSNumber(value: 10), kCGWindowName as String: "iCloud Passwords"] + ] + let result = BrowserAutofill.selectCandidates(windowInfo: windows, eligiblePIDs: [42]) + XCTAssertEqual(result, [BrowserAutofill.CandidateDescriptor(identity: .init(processIdentifier: 42, windowNumber: 10), requiresTrustedOrigin: false)]) + } + + func test_origin_gated_candidate_selection_marks_untitled_fallback() { + let windows: [[String: Any]] = [[kCGWindowOwnerPID as String: NSNumber(value: 42), kCGWindowNumber as String: NSNumber(value: 9), kCGWindowName as String: ""]] + let result = BrowserAutofill.selectCandidates(windowInfo: windows, eligiblePIDs: [42]) + XCTAssertEqual(result, [BrowserAutofill.CandidateDescriptor(identity: .init(processIdentifier: 42, windowNumber: 9), requiresTrustedOrigin: true)]) + } + + func test_browser_extension_popup_url_helper() { + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL("chrome-extension://id/page_popup.html")) + XCTAssertTrue(AuthorizationContext.isBrowserExtensionPopupURL("moz-extension://id/page_popup.html?x=1")) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL("https://id/page_popup.html")) + XCTAssertFalse(AuthorizationContext.isBrowserExtensionPopupURL("chrome-extension://id/other.html")) + } + + func test_origin_gated_target_requires_ax_url_context_and_six_fields() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["https://example.com/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 6 + )) + XCTAssertFalse(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: true, + diagnosticURLs: ["chrome-extension://id/page_popup.html"], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 5 + )) + } + + func test_title_candidate_retains_legacy_target_acceptance() { + XCTAssertTrue(BrowserAutofill.acceptsTarget( + requiresTrustedOrigin: false, + diagnosticURLs: [], + hasAuthorizationContext: true, + hasStablePopupSignature: false, + inputCount: 1 + )) + } } diff --git a/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md index bf5fc0f..5ac7aa4 100644 --- a/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md +++ b/docs/superpowers/plans/2026-08-04-arc-origin-gated-autofill.md @@ -4,7 +4,7 @@ **Goal:** Allow Arc's untitled iCloud Passwords extension popup to use the existing fill path only after a trusted AXURL origin, authorization context, and six input fields are verified. -**Architecture:** Candidate discovery emits one candidate per permitted browser PID: a title-matched window when present, otherwise the PID's topmost visible window marked origin-gated. Title candidates preserve legacy target matching; origin-gated candidates may succeed only with an AX-attribute `chrome-extension`/`moz-extension` popup URL, existing context, and six inputs. Diagnostics explicitly record whether an extension URL came from AX attributes. +**Architecture:** Candidate discovery and fallback acceptance land atomically: one candidate per permitted browser PID is title-matched when present or topmost-visible and origin-gated otherwise. An origin-gated candidate may succeed only with a parsed AX-attribute `chrome-extension`/`moz-extension` URL whose path is exactly `/page_popup.html`, existing context, and six inputs. Diagnostics explicitly record whether an extension URL came from AX attributes. **Tech Stack:** Swift 5.10, AppKit/ApplicationServices/CoreGraphics, XCTest, SwiftPM. @@ -46,6 +46,8 @@ git commit -m "feat: collect Arc authorization origin evidence" - Modify: `Sources/ApplePasswordBridge/CodeParser.swift:26-48` - Test: `Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift` +**Safety boundary:** This task may add pure helpers only. Do not wire fallback descriptors into `authorizationWindowCandidates` until Task 2 adds the matching trusted-origin gate in the same commit. + - [ ] **Step 1: Write failing pure selection and URL-origin tests** Add tests around a new internal helper that selects a single `WindowIdentity` plus `requiresTrustedOrigin` per eligible PID from CG dictionaries: @@ -126,10 +128,13 @@ Add the pure authorization helper: ```swift static func isBrowserExtensionPopupURL(_ url: String) -> Bool { - let lower = url.lowercased() - let isExtension = lower.hasPrefix("chrome-extension://") - || lower.hasPrefix("moz-extension://") - return isExtension && lower.contains("/page_popup.html") + guard let components = URLComponents(string: url), + let scheme = components.scheme?.lowercased(), + ["chrome-extension", "moz-extension"].contains(scheme), + components.host != nil else { + return false + } + return components.path == "/page_popup.html" } ``` @@ -141,10 +146,10 @@ Run the focused tests and the complete suite. Expected: all tests pass. git add Sources/ApplePasswordBridge/FirefoxAutofill.swift \ Sources/ApplePasswordBridge/CodeParser.swift \ Tests/ApplePasswordBridgeTests/BrowserDiagnosticsTests.swift -git commit -m "feat: select origin-gated browser candidates" +git commit -m "feat: add origin-gated candidate helpers" ``` -### Task 2: Gate fallback fill targets on AXURL origin, context, and six inputs +### Task 2: Atomically wire and gate fallback fill targets on AXURL origin, context, and six inputs **Files:** - Modify: `Sources/ApplePasswordBridge/FirefoxAutofill.swift:32-125` @@ -209,7 +214,7 @@ Extend `Candidate`: let requiresTrustedOrigin: Bool ``` -Rewrite `authorizationWindowCandidates(policy:)` to build `eligibleApplicationsByPID`, call `selectCandidates(windowInfo:eligiblePIDs:)`, and map descriptors to applications. Preserve exactly one candidate per eligible PID. Do not activate or focus applications in this method. +Rewrite `authorizationWindowCandidates(policy:)` to build `eligibleApplicationsByPID`, call `selectCandidates(windowInfo:eligiblePIDs:)`, and map descriptors to applications. Preserve exactly one candidate per eligible PID. This wiring and Step 4's gate must be committed together. Do not activate or focus applications in this method. - [ ] **Step 4: Implement the pure target gate and use it in locateTarget** @@ -266,7 +271,7 @@ Run the focused evaluator test. Expected: the current evaluator returns `.window - [ ] **Step 3: Add source-provenance boolean** -Add `hasAccessibilityExtensionURL` to `DiagnosticAccessibilityWindow`, defaulting to `false` at the end of its initializer. In `makeAccessibilityObservation`, set it only when a raw `DiagnosticNode.diagnosticURL` itself passes `DiagnosticRedactor.extensionURLSummary`; text-discovered URLs must leave it false. Render it as `axURL=true` or `axURL=false` in the deterministic AX report line and include it in the AX sort key. +Add `hasAccessibilityExtensionURL` and `hasAccessibilityPopupURL` to `DiagnosticAccessibilityWindow`, defaulting to `false` at the end of its initializer. In `makeAccessibilityObservation`, set the former only when a raw `DiagnosticNode.diagnosticURL` itself passes `DiagnosticRedactor.extensionURLSummary`, and set the latter only when that raw AX URL satisfies `isBrowserExtensionPopupURL`; text-discovered URLs and paths must leave both false. Render both provenance flags in the deterministic AX report line and include them in the AX sort key. - [ ] **Step 4: Prioritize trusted AX targets in evaluate** @@ -274,8 +279,7 @@ Before the CG title mismatch guard, compute trusted AX windows: ```swift let trusted = permitted.flatMap(\.accessibilityWindows).filter { - $0.hasAccessibilityExtensionURL - && $0.hasPopupPath + $0.hasAccessibilityPopupURL && $0.authorizationContextMatches } if trusted.contains(where: { $0.supportedInputCount >= 6 }) { diff --git a/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md index ff0dc2d..610e1df 100644 --- a/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md +++ b/docs/superpowers/specs/2026-08-04-arc-origin-gated-autofill-design.md @@ -19,20 +19,24 @@ The current production gate rejects this window before AX inspection because its Keep the existing Core Graphics title match as the preferred fast path. For each permitted running browser process that has no titled candidate, select one fallback candidate from its topmost on-screen Core Graphics window. This is only a scheduling identity for retry and handled-window bookkeeping; it is not a trust signal. -The fallback candidate receives the same read-only AX traversal as a titled candidate, but it may return a fill target only if a single AX window satisfies every condition below: +Candidate selection and fallback target gating are one atomic production change. The fallback candidate receives the same read-only AX traversal as a titled candidate, but it may return a fill target only if a single AX window satisfies every condition below: -1. an AX attribute URL, not text scraping, has either the `chrome-extension` or `moz-extension` scheme and a `/page_popup.html` path; +1. an AX attribute URL, not text scraping, parses with either the `chrome-extension` or `moz-extension` scheme, has an extension host, and has the exact `/page_popup.html` path; 2. existing authorization context recognizes an iCloud/extension identity plus autofill and verification-code terms; 3. the AX window exposes at least six supported text inputs. The selected AX window is still the one raised and filled by the existing code. The Core Graphics fallback identity is never used to infer extension origin or select fields. +The fallback identity is an ordering and retry key only. It never binds an AX target to a Core Graphics window: the AX window that passes all three checks is the only window that can be raised or filled. + ## Candidate and Retry Behavior Candidate discovery reads Core Graphics windows once per scan. For each eligible PID, it selects the first title-matching window in window-list order; if none matches, it selects that PID's first visible window in the same order and marks it as origin-gated. At most one candidate is emitted per PID. Existing `WindowIdentity`, retry backoff, and handled-window storage remain unchanged. Because the fallback identity follows the topmost window, a newly shown popup obtains a new identity and is eligible for an immediate AX check; an unchanged browser window remains governed by the existing retry backoff. No new timer, AX observer, window-size threshold, or private API is introduced. +The pre-existing retry path returns before code capture and Vision OCR whenever no AX target passes the gate. Therefore non-matching fallback candidates do not invoke OCR. + ## Security Boundary The legacy title-matched path retains its current behavior. The new titleless path is stricter: it requires an AXURL-derived extension popup origin, full existing authorization context, and six supported inputs. A window title, empty title, dimensions, visible strings, bundle ID, or role alone cannot authorize automatic filling. @@ -41,7 +45,7 @@ The implementation must keep raw extension URLs transient. It may use them to te ## Diagnostic Semantics -After the production predicate exists, the diagnostic evaluator should report `target_recognized` for a permitted application's AX window with trusted extension URL, context, and supported inputs even if every CG title mismatches. `window_title_mismatch` remains the result only when no trusted AX target is present. +After the production predicate exists, the diagnostic evaluator should report `target_recognized` for a permitted application's AX window with an AX-attribute URL that independently satisfies the same extension scheme, host, and exact popup-path predicate, plus context and supported inputs, even if every CG title mismatches. Text-derived URL and path hints remain diagnostic-only and cannot form a trusted target. `window_title_mismatch` remains the result only when no trusted AX target is present. ## Tests and Manual Validation