blob: 6ddc7d280ebdf270b6342dca683a0311458cdbbb [file]
// An accessibility oracle that owes Computer Use nothing.
//
// Every real-machine check so far read the result back through the same path
// that wrote it: the model acts through maka-cu, and then the observation
// that says it worked also comes from maka-cu. An executor that reports a
// successful click on a control it never touched passes that test. So does a
// locked screen, which is how a menu-only tree once read as a normal one.
//
// This walks the accessibility tree itself, through the system framework, in a
// separate process. When it and an observation disagree, the observation is
// the one that has to explain itself.
//
// Usage:
// swift cu-ax-oracle.swift <bundle-id> [--role AXButton] [--depth 8]
// Prints one JSON object: the app, its windows, and every element with a role,
// a label, and a value. Exit 0 means the object describes the tree. Exit 1
// means it describes why there is no tree to describe — `screen_locked`,
// `not_permitted`, `not_running` — and the caller must not read the absence of
// an element as evidence the element is absent. Exit 64 is a usage error.
//
// This is a hand-run tool. It has no automated caller in this repository: it is
// meant to be run beside a Computer Use session, by a person, when an
// observation needs a second opinion.
import AppKit
import ApplicationServices
import Foundation
let arguments = CommandLine.arguments
guard arguments.count >= 2 else {
FileHandle.standardError.write("usage: cu-ax-oracle.swift <bundle-id> [--role R] [--depth N]\n".data(using: .utf8)!)
exit(64)
}
let bundleId = arguments[1]
var roleFilter: String?
var maxDepth = 12
var index = 2
while index < arguments.count {
switch arguments[index] {
case "--role" where index + 1 < arguments.count:
roleFilter = arguments[index + 1]
index += 2
case "--depth" where index + 1 < arguments.count:
maxDepth = Int(arguments[index + 1]) ?? maxDepth
index += 2
default:
index += 1
}
}
func emit(_ object: [String: Any], status: Int32 = 0) -> Never {
let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
exit(status)
}
// A state this oracle cannot see through. It is printed as JSON like everything
// else, because a caller may want to read the reason — but it exits non-zero,
// so `cu-ax-oracle.swift X > witness.json && assert ...` cannot walk on with a
// file that says the tree is empty because the oracle was blindfolded. Exiting
// 0 here is the same mistake as an observation that reports success for a
// dispatch that never happened.
func fail(_ object: [String: Any]) -> Never {
emit(object, status: 1)
}
// The screen lock reshapes every accessibility tree at once, and it does it
// without an error. Report it as a distinct state rather than as an app with
// no windows — that confusion cost a previous session most of an afternoon.
let sessionDictionary = CGSessionCopyCurrentDictionary() as? [String: Any]
if (sessionDictionary?["CGSSessionScreenIsLocked"] as? Int) == 1 {
fail(["error": "screen_locked"])
}
// Without Accessibility permission every attribute read fails and nothing says
// so. `AXUIElementCopyAttributeValue` returns `.apiDisabled`, `windows` comes
// back empty, and this would emit a well-formed
// `{"window_count":0,"element_count":0,"truncated":false}` — indistinguishable
// from an application that genuinely has nothing in it. A witness that reports
// an empty world when it has been blindfolded is worse than no witness, and on
// this project a node process losing its TCC grant is the likeliest way it
// happens. So it is the second thing checked, after the lock.
if !AXIsProcessTrusted() {
fail([
"error": "not_permitted",
"detail":
"the process running this oracle has no Accessibility permission, so every attribute read would fail and every tree would read as empty",
])
}
guard
let running = NSWorkspace.shared.runningApplications.first(where: {
$0.bundleIdentifier == bundleId
})
else {
fail(["error": "not_running", "bundle_id": bundleId])
}
let pid = running.processIdentifier
func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else {
return nil
}
return value
}
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
guard let value = copyAttribute(element, attribute) else { return nil }
if let string = value as? String { return string }
if CFGetTypeID(value) == AXValueGetTypeID() { return nil }
if let number = value as? NSNumber { return number.stringValue }
return nil
}
func children(_ element: AXUIElement) -> [AXUIElement] {
guard let value = copyAttribute(element, kAXChildrenAttribute as String) else { return [] }
return (value as? [AXUIElement]) ?? []
}
func frame(_ element: AXUIElement) -> [String: Double]? {
guard
let positionValue = copyAttribute(element, kAXPositionAttribute as String),
let sizeValue = copyAttribute(element, kAXSizeAttribute as String)
else { return nil }
var point = CGPoint.zero
var size = CGSize.zero
guard
AXValueGetValue(positionValue as! AXValue, .cgPoint, &point),
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
else { return nil }
return ["x": point.x, "y": point.y, "w": size.width, "h": size.height]
}
var elements: [[String: Any]] = []
// Two cuts, reported separately, and either one makes `truncated` true.
//
// The depth cut used to prune the subtree and leave `truncated` false. Every
// consumer of this file makes negative assertions off it — "the sidebar item is
// gone", "one of the two windows is gone", "the document is empty" — and a
// depth cut read that way is a fact about the walk being reported as a fact
// about the world. `--depth` defaults to 12, which is shallower than a real
// Electron tree, so this was not a corner case.
var budgetTruncated = false
var depthTruncated = false
var deepestCut = 0
let elementBudget = 4000
func walk(_ element: AXUIElement, depth: Int, path: String) {
if elements.count >= elementBudget {
budgetTruncated = true
return
}
if depth > maxDepth {
depthTruncated = true
deepestCut = max(deepestCut, depth)
return
}
let role = stringAttribute(element, kAXRoleAttribute as String) ?? "?"
if roleFilter == nil || role == roleFilter {
var record: [String: Any] = ["role": role, "depth": depth, "path": path]
if let title = stringAttribute(element, kAXTitleAttribute as String), !title.isEmpty {
record["title"] = title
}
if let description = stringAttribute(element, kAXDescriptionAttribute as String),
!description.isEmpty
{
record["description"] = description
}
if let value = stringAttribute(element, kAXValueAttribute as String), !value.isEmpty {
record["value"] = value.count > 400 ? String(value.prefix(400)) + "…" : value
}
if let identifier = stringAttribute(element, "AXIdentifier"), !identifier.isEmpty {
record["identifier"] = identifier
}
if let bounds = frame(element) { record["frame"] = bounds }
elements.append(record)
}
for (childIndex, child) in children(element).enumerated() {
walk(child, depth: depth + 1, path: "\(path)/\(childIndex)")
}
}
let application = AXUIElementCreateApplication(pid)
// The menu bar is not part of any window, and including it is what makes an
// app look like it has hundreds of controls it does not have. Walk the windows.
let windowValue = copyAttribute(application, kAXWindowsAttribute as String)
let windows = (windowValue as? [AXUIElement]) ?? []
var windowRecords: [[String: Any]] = []
for (windowIndex, window) in windows.enumerated() {
var record: [String: Any] = ["index": windowIndex]
if let title = stringAttribute(window, kAXTitleAttribute as String) { record["title"] = title }
if let bounds = frame(window) { record["frame"] = bounds }
if let subrole = stringAttribute(window, kAXSubroleAttribute as String) {
record["subrole"] = subrole
}
windowRecords.append(record)
walk(window, depth: 0, path: "w\(windowIndex)")
}
emit([
"bundle_id": bundleId,
"pid": Int(pid),
"localized_name": running.localizedName ?? "",
"active": running.isActive,
"window_count": windows.count,
"windows": windowRecords,
"element_count": elements.count,
"truncated": budgetTruncated || depthTruncated,
"truncated_by_budget": budgetTruncated,
"truncated_by_depth": depthTruncated,
"deepest_cut": deepestCut,
"max_depth": maxDepth,
"elements": elements,
])