wrangle 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +94 -3
- data/exe/wrangle +556 -33
- data/lib/wrangle/decision_provider.rb +128 -0
- data/lib/wrangle/desktop_autonomy.rb +61 -0
- data/lib/wrangle/desktop_decider.rb +263 -0
- data/lib/wrangle/desktop_dispatch.rb +67 -0
- data/lib/wrangle/desktop_effect.rb +198 -0
- data/lib/wrangle/desktop_observation.rb +252 -0
- data/lib/wrangle/desktop_policy.rb +47 -0
- data/lib/wrangle/desktop_progressive_observation.rb +34 -0
- data/lib/wrangle/desktop_proposal.rb +75 -0
- data/lib/wrangle/desktop_session_autonomy.rb +344 -0
- data/lib/wrangle/desktop_session_server.rb +374 -0
- data/lib/wrangle/desktop_task.rb +67 -0
- data/lib/wrangle/errors.rb +36 -0
- data/lib/wrangle/event_log.rb +51 -0
- data/lib/wrangle/jev.rb +4 -2
- data/lib/wrangle/macos/helper.swift +593 -0
- data/lib/wrangle/macos_driver.rb +200 -0
- data/lib/wrangle/macos_helper.rb +194 -0
- data/lib/wrangle/observation.rb +1 -1
- data/lib/wrangle/provider_conformance.jsonl +8 -0
- data/lib/wrangle/provider_factory.rb +106 -0
- data/lib/wrangle/provider_qualification.rb +73 -0
- data/lib/wrangle/run_loop.rb +4 -2
- data/lib/wrangle/safari.rb +7 -4
- data/lib/wrangle/scope_registry.rb +152 -0
- data/lib/wrangle/session_server.rb +15 -9
- data/lib/wrangle/tart_guest_driver.rb +251 -0
- data/lib/wrangle/timing.rb +12 -0
- data/lib/wrangle/version.rb +1 -1
- data/lib/wrangle.rb +20 -1
- data/skills/wrangle/SKILL.md +37 -4
- metadata +29 -7
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wrangle
|
|
4
|
+
# Verifies the intended target-specific postcondition after a delivered native action. A changed
|
|
5
|
+
# desktop revision is never proof by itself: unrelated animation, status text, or another control
|
|
6
|
+
# may have changed at the same time. When the intended target cannot be identified or its required
|
|
7
|
+
# state is outside partial coverage, the effect remains unverified.
|
|
8
|
+
class DesktopEffect
|
|
9
|
+
OPERATIONS = %w[PRESS SET_TEXT CLEAR TOGGLE EXPAND COLLAPSE].freeze
|
|
10
|
+
TOGGLE_ROLES = %w[checkbox switch radiobutton].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def verify(proposal, before, after)
|
|
14
|
+
return "unverified" unless before.is_a?(Hash) && after.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
operation = proposal["operation"].to_s
|
|
17
|
+
candidate = proposal["candidate"]
|
|
18
|
+
return "unverified" unless OPERATIONS.include?(operation) && candidate.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
match, target = find_target(candidate, after)
|
|
21
|
+
return missing_target_effect(operation, before, after) if match == :missing
|
|
22
|
+
return "unverified" unless match == :found
|
|
23
|
+
|
|
24
|
+
case operation
|
|
25
|
+
when "PRESS" then verify_press(candidate, target, before, after)
|
|
26
|
+
when "SET_TEXT" then verify_set_text(candidate, target, proposal["text"], before, after)
|
|
27
|
+
when "CLEAR" then verify_clear(candidate, target, before, after)
|
|
28
|
+
when "TOGGLE" then verify_toggle(candidate, target, before, after)
|
|
29
|
+
when "EXPAND", "COLLAPSE" then verify_expansion(operation, candidate, target, before, after)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def find_target(candidate, observation)
|
|
36
|
+
elements = observation_elements(observation)
|
|
37
|
+
target_key = candidate["target_key"].to_s
|
|
38
|
+
unless target_key.empty?
|
|
39
|
+
keyed = elements.select do |element|
|
|
40
|
+
element["target_key"] == target_key && element["role"].to_s == candidate["role"].to_s
|
|
41
|
+
end
|
|
42
|
+
return [:found, keyed.first] if keyed.one?
|
|
43
|
+
return [:ambiguous, nil] if keyed.length > 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
visible = elements.select { |element| same_visible_identity?(candidate, element) }
|
|
47
|
+
return [:found, visible.first] if visible.one?
|
|
48
|
+
return [:missing, nil] if visible.empty?
|
|
49
|
+
|
|
50
|
+
[:ambiguous, nil]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def observation_elements(observation)
|
|
54
|
+
tree = observation["tree"]
|
|
55
|
+
return Array(observation["candidates"]) unless tree.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
elements = []
|
|
58
|
+
stack = [tree]
|
|
59
|
+
until stack.empty?
|
|
60
|
+
node = stack.pop
|
|
61
|
+
elements << normalize_tree_node(node)
|
|
62
|
+
stack.concat(Array(node["children"]).grep(Hash))
|
|
63
|
+
end
|
|
64
|
+
elements
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_tree_node(node)
|
|
68
|
+
{
|
|
69
|
+
"role" => node["role"], "subrole" => node["subrole"], "label" => node["name"],
|
|
70
|
+
"value" => node["value"], "states" => node["states"], "operations" => node["operations"],
|
|
71
|
+
"children_count" => node["children_count"], "target_key" => node["target_key"]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def same_visible_identity?(before, after)
|
|
76
|
+
before["role"].to_s == after["role"].to_s &&
|
|
77
|
+
before["subrole"].to_s == after["subrole"].to_s &&
|
|
78
|
+
before["label"].to_s == after["label"].to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def missing_target_effect(operation, before, after)
|
|
82
|
+
return "unverified" unless operation == "PRESS" && complete?(after)
|
|
83
|
+
|
|
84
|
+
changed_revision?(before, after) ? "verified" : "unverified"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def verify_press(candidate, target, before, after)
|
|
88
|
+
if calculator_digit_press_effect?(candidate, before, after)
|
|
89
|
+
return changed_revision?(before, after) ? "verified" : "unverified"
|
|
90
|
+
end
|
|
91
|
+
return "unchanged" if press_signature(candidate) == press_signature(target)
|
|
92
|
+
|
|
93
|
+
changed_revision?(before, after) ? "verified" : "unverified"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def calculator_digit_press_effect?(candidate, before, after)
|
|
97
|
+
digit = candidate["label"].to_s
|
|
98
|
+
return false unless candidate["role"] == "button" && digit.match?(/\A\d\z/)
|
|
99
|
+
return false unless same_calculator_scope?(before, after) && complete?(before) && complete?(after)
|
|
100
|
+
|
|
101
|
+
previous = calculator_display(before)
|
|
102
|
+
current = calculator_display(after)
|
|
103
|
+
return false unless previous && current
|
|
104
|
+
|
|
105
|
+
expected = previous == "0" ? digit : "#{previous}#{digit}"
|
|
106
|
+
current == expected
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def same_calculator_scope?(before, after)
|
|
110
|
+
before_scope = before["scope"]
|
|
111
|
+
after_scope = after["scope"]
|
|
112
|
+
before_scope.is_a?(Hash) && after_scope.is_a?(Hash) &&
|
|
113
|
+
before_scope["app"] == "Calculator" && after_scope["app"] == "Calculator" &&
|
|
114
|
+
!before_scope["id"].to_s.empty? && before_scope["id"] == after_scope["id"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def calculator_display(observation)
|
|
118
|
+
values = observation_elements(observation).filter_map do |element|
|
|
119
|
+
next unless element["role"] == "statictext" && element["value"].is_a?(String)
|
|
120
|
+
|
|
121
|
+
normalized = element["value"].gsub(/[^0-9.-]/, "")
|
|
122
|
+
normalized unless normalized.empty?
|
|
123
|
+
end.uniq
|
|
124
|
+
values.one? ? values.first : nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def press_signature(candidate)
|
|
128
|
+
{
|
|
129
|
+
"label" => candidate["label"].to_s, "value" => candidate["value"],
|
|
130
|
+
"states" => Array(candidate["states"]).map(&:to_s).reject { |state| state == "focused" }.uniq.sort,
|
|
131
|
+
"operations" => Array(candidate["operations"]).map(&:to_s).reject { |op| op == "DRILL" }.uniq.sort,
|
|
132
|
+
"children_count" => candidate["children_count"]
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def verify_set_text(candidate, target, text, before, after)
|
|
137
|
+
return "unverified" unless text.is_a?(String) && same_visible_identity?(candidate, target)
|
|
138
|
+
return "unchanged" unless target["value"] == text
|
|
139
|
+
return "unchanged" if candidate["value"] == text
|
|
140
|
+
|
|
141
|
+
changed_revision?(before, after) ? "verified" : "unverified"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def verify_clear(candidate, target, before, after)
|
|
145
|
+
return "unverified" unless same_visible_identity?(candidate, target) && target["value"].is_a?(String)
|
|
146
|
+
return "unchanged" unless target["value"].strip.empty?
|
|
147
|
+
return "unchanged" if candidate["value"].is_a?(String) && candidate["value"].strip.empty?
|
|
148
|
+
|
|
149
|
+
changed_revision?(before, after) ? "verified" : "unverified"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def verify_toggle(candidate, target, before, after)
|
|
153
|
+
return "unverified" unless same_visible_identity?(candidate, target)
|
|
154
|
+
|
|
155
|
+
previous = toggle_value(candidate)
|
|
156
|
+
current = toggle_value(target)
|
|
157
|
+
return "unverified" if previous.nil? || current.nil?
|
|
158
|
+
return "unchanged" if previous == current
|
|
159
|
+
|
|
160
|
+
changed_revision?(before, after) ? "verified" : "unverified"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def toggle_value(candidate)
|
|
164
|
+
return unless TOGGLE_ROLES.include?(candidate["role"].to_s)
|
|
165
|
+
|
|
166
|
+
scalar = candidate["value"]
|
|
167
|
+
return scalar if [true, false].include?(scalar)
|
|
168
|
+
return scalar == 1 if [0, 1].include?(scalar)
|
|
169
|
+
|
|
170
|
+
states = Array(candidate["states"]).map(&:to_s)
|
|
171
|
+
states.include?("checked") || states.include?("selected")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def verify_expansion(operation, candidate, target, before, after)
|
|
175
|
+
return "unverified" unless same_visible_identity?(candidate, target) &&
|
|
176
|
+
candidate.key?("states") && target.key?("states")
|
|
177
|
+
|
|
178
|
+
previous = Array(candidate["states"]).map(&:to_s).include?("expanded")
|
|
179
|
+
current = Array(target["states"]).map(&:to_s).include?("expanded")
|
|
180
|
+
expected_previous = operation == "COLLAPSE"
|
|
181
|
+
return "unverified" unless previous == expected_previous
|
|
182
|
+
return "unchanged" if previous == current
|
|
183
|
+
|
|
184
|
+
changed_revision?(before, after) ? "verified" : "unverified"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def complete?(observation)
|
|
188
|
+
observation["complete"] == true && observation.dig("coverage", "truncated") != true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def changed_revision?(before, after)
|
|
192
|
+
previous = before["revision"]
|
|
193
|
+
current = after["revision"]
|
|
194
|
+
previous.is_a?(String) && current.is_a?(String) && !previous.empty? && previous != current
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "observation"
|
|
8
|
+
|
|
9
|
+
module Wrangle
|
|
10
|
+
# Normalizes a driver's hierarchical AX snapshot without throwing away its progressive structure.
|
|
11
|
+
class DesktopObservation
|
|
12
|
+
TEXT_ROLES = %w[textfield textarea searchfield combobox].freeze
|
|
13
|
+
TOGGLE_ROLES = %w[checkbox switch radiobutton].freeze
|
|
14
|
+
WINDOW_CONTROL = /\A(?:close|close window|close all|quit(?: .*)?|minimize|zoom|full ?screen)\z/i
|
|
15
|
+
VISIBLE_TEXT_CHARS = 500
|
|
16
|
+
|
|
17
|
+
attr_reader :state
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Provider and result evidence includes non-actionable text as well as controls. Returning only
|
|
21
|
+
# candidates made read-only tasks impossible to prove and allowed DONE without seeing the fact.
|
|
22
|
+
def evidence_items(observation)
|
|
23
|
+
items = []
|
|
24
|
+
seen = {}
|
|
25
|
+
collect_evidence(observation["tree"], items, seen) if observation["tree"].is_a?(Hash)
|
|
26
|
+
Array(observation["candidates"]).each do |candidate|
|
|
27
|
+
add_evidence(items, seen, candidate.slice("role", "label", "value", "states"))
|
|
28
|
+
end
|
|
29
|
+
items
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# An action is addressable only by the semantic fields exposed to the provider. Opaque AX refs,
|
|
33
|
+
# paths, and target keys must never break a tie the provider cannot see.
|
|
34
|
+
def action_descriptor(candidate, operation)
|
|
35
|
+
{
|
|
36
|
+
"operation" => operation.to_s, "role" => candidate["role"].to_s,
|
|
37
|
+
"label" => bounded_visible(candidate["label"].to_s),
|
|
38
|
+
"value" => bounded_visible(scalar_value(candidate["value"])),
|
|
39
|
+
"states" => Array(candidate["states"]).map(&:to_s).uniq.sort
|
|
40
|
+
}.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def action_ambiguities(candidates)
|
|
44
|
+
entries = action_entries(candidates)
|
|
45
|
+
counts = entries.tally
|
|
46
|
+
seen = {}
|
|
47
|
+
entries.filter_map do |descriptor|
|
|
48
|
+
next unless counts[descriptor] > 1
|
|
49
|
+
next if seen[descriptor]
|
|
50
|
+
|
|
51
|
+
seen[descriptor] = true
|
|
52
|
+
descriptor.merge("matches" => counts[descriptor])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def action_unambiguous?(candidates, candidate, operation)
|
|
57
|
+
wanted = action_descriptor(candidate, operation)
|
|
58
|
+
Array(candidates).one? do |possible|
|
|
59
|
+
Array(possible["operations"]).map(&:to_s).uniq.include?(operation.to_s) &&
|
|
60
|
+
action_descriptor(possible, operation) == wanted
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reject_ambiguous_actions(candidates)
|
|
65
|
+
ambiguities = action_ambiguities(candidates)
|
|
66
|
+
ambiguous = ambiguities.to_h { |descriptor| [descriptor.except("matches"), true] }
|
|
67
|
+
filtered = Array(candidates).filter_map do |candidate|
|
|
68
|
+
operations = Array(candidate["operations"]).map(&:to_s).uniq.reject do |operation|
|
|
69
|
+
ambiguous[action_descriptor(candidate, operation)]
|
|
70
|
+
end
|
|
71
|
+
candidate.merge("operations" => operations) unless operations.empty?
|
|
72
|
+
end
|
|
73
|
+
[filtered, ambiguities]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def action_entries(candidates)
|
|
79
|
+
Array(candidates).flat_map do |candidate|
|
|
80
|
+
Array(candidate["operations"]).map(&:to_s).uniq.map do |operation|
|
|
81
|
+
action_descriptor(candidate, operation)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def bounded_visible(value)
|
|
87
|
+
return value unless value.is_a?(String) && value.length > VISIBLE_TEXT_CHARS
|
|
88
|
+
|
|
89
|
+
"#{value[0, VISIBLE_TEXT_CHARS]}…"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def collect_evidence(node, items, seen)
|
|
93
|
+
visible = { "role" => node["role"].to_s.downcase, "label" => node["name"].to_s,
|
|
94
|
+
"value" => scalar_value(node["value"]), "states" => Array(node["states"]).map(&:to_s) }
|
|
95
|
+
add_evidence(items, seen, visible) unless visible["role"] == "window"
|
|
96
|
+
Array(node["children"]).each do |child|
|
|
97
|
+
collect_evidence(child, items, seen) if child.is_a?(Hash)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_evidence(items, seen, visible)
|
|
102
|
+
visible = visible.compact
|
|
103
|
+
label = visible["label"].to_s
|
|
104
|
+
value = visible["value"]
|
|
105
|
+
return if label.empty? && (value.nil? || value.to_s.empty?)
|
|
106
|
+
return if seen[visible]
|
|
107
|
+
|
|
108
|
+
seen[visible] = true
|
|
109
|
+
items << visible
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def scalar_value(value)
|
|
113
|
+
value if value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def initialize(scope:, snapshot:, window:, driver: "macos", read_only: false)
|
|
118
|
+
@scope = scope
|
|
119
|
+
@snapshot = snapshot
|
|
120
|
+
@window = window
|
|
121
|
+
@driver = driver
|
|
122
|
+
@read_only = read_only
|
|
123
|
+
validate!
|
|
124
|
+
@state = build
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def revision = @state.fetch("revision")
|
|
128
|
+
def candidates = @state.fetch("candidates")
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def validate!
|
|
133
|
+
unless @snapshot.is_a?(Hash) && @snapshot["snapshot_id"].is_a?(String) && @snapshot["tree"].is_a?(Hash)
|
|
134
|
+
raise DriverError, "Desktop observation requires a complete driver snapshot"
|
|
135
|
+
end
|
|
136
|
+
unless @window.is_a?(Hash) && @window["id"] == @scope.root &&
|
|
137
|
+
@window["process_instance"] == @scope.process_instance
|
|
138
|
+
raise ScopeLost, "The observed window is no longer the attached root surface"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
observed_id = @snapshot.dig("window", "id")
|
|
142
|
+
raise ScopeLost, "The AX snapshot belongs to another window" if observed_id && observed_id != @scope.root
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build
|
|
146
|
+
observed_candidates = []
|
|
147
|
+
walk(@snapshot["tree"], observed_candidates)
|
|
148
|
+
candidates, ambiguous_actions = self.class.reject_ambiguous_actions(observed_candidates)
|
|
149
|
+
body = {
|
|
150
|
+
"schema" => "wrangle.observation.v1", "driver" => @driver,
|
|
151
|
+
"scope" => { "id" => @scope.id, "root" => @scope.root, "app" => @scope.app,
|
|
152
|
+
"pid" => @scope.pid, "process_instance" => @scope.process_instance },
|
|
153
|
+
"window" => @window.slice("id", "bounds", "focused", "visible"),
|
|
154
|
+
"snapshot_id" => @snapshot["snapshot_id"], "complete" => @snapshot.fetch("complete", true),
|
|
155
|
+
"tree" => @snapshot["tree"], "candidates" => candidates,
|
|
156
|
+
"ambiguous_actions" => ambiguous_actions,
|
|
157
|
+
"coverage" => coverage(candidates, ambiguous_actions)
|
|
158
|
+
}
|
|
159
|
+
body["read_only"] = true if @read_only
|
|
160
|
+
body["revision"] = fingerprint(body)
|
|
161
|
+
body
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def walk(node, candidates)
|
|
165
|
+
candidate = candidate(node)
|
|
166
|
+
candidates << candidate if candidate
|
|
167
|
+
Array(node["children"]).each { |child| walk(child, candidates) if child.is_a?(Hash) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def candidate(node)
|
|
171
|
+
ref = node["ref_id"]
|
|
172
|
+
return nil unless ref.is_a?(String)
|
|
173
|
+
|
|
174
|
+
role = node["role"].to_s.downcase
|
|
175
|
+
operations = operations(role, node)
|
|
176
|
+
operations &= ["DRILL"] if Array(node["states"]).map(&:to_s).include?("disabled")
|
|
177
|
+
operations -= ["PRESS"] if window_control?(role, node)
|
|
178
|
+
return nil if operations.empty?
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
"ref" => ref, "role" => role, "subrole" => node["subrole"], "label" => node["name"].to_s,
|
|
182
|
+
"value" => scalar(node["value"]), "states" => Array(node["states"]).map(&:to_s),
|
|
183
|
+
"operations" => operations, "children_count" => node["children_count"],
|
|
184
|
+
"target_key" => node["target_key"]
|
|
185
|
+
}.compact
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def window_control?(_role, node)
|
|
189
|
+
subrole = node["subrole"].to_s.delete_prefix("AX").downcase
|
|
190
|
+
%w[closebutton minimizebutton zoombutton fullscreenbutton].include?(subrole) ||
|
|
191
|
+
node["name"].to_s.match?(WINDOW_CONTROL)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def operations(role, node)
|
|
195
|
+
observed = node["operations"]
|
|
196
|
+
return native_operations(observed, node) if observed.is_a?(Array)
|
|
197
|
+
|
|
198
|
+
offered = []
|
|
199
|
+
offered << "DRILL" if node["children_count"].to_i.positive?
|
|
200
|
+
offered.push("SET_TEXT", "CLEAR") if TEXT_ROLES.include?(role)
|
|
201
|
+
offered << "TOGGLE" if TOGGLE_ROLES.include?(role)
|
|
202
|
+
offered << "PRESS" unless role.empty? || role == "window"
|
|
203
|
+
offered
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def native_operations(observed, node)
|
|
207
|
+
allowed = %w[PRESS SET_TEXT CLEAR TOGGLE EXPAND COLLAPSE]
|
|
208
|
+
offered = observed.map(&:to_s) & allowed
|
|
209
|
+
offered.unshift("DRILL") if node["children_count"].to_i.positive?
|
|
210
|
+
offered.uniq
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def scalar(value)
|
|
214
|
+
value if value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def coverage(candidates, ambiguous_actions)
|
|
218
|
+
truncated = @snapshot["complete"] == false || truncated?(@snapshot["tree"])
|
|
219
|
+
provenance = Array(@snapshot["provenance"])
|
|
220
|
+
provenance = ["ax"] if provenance.empty?
|
|
221
|
+
{ "provenance" => provenance, "truncated" => truncated,
|
|
222
|
+
"candidate_count" => candidates.length,
|
|
223
|
+
"ambiguous_action_count" => ambiguous_actions.sum { |action| action.fetch("matches") },
|
|
224
|
+
"ocr" => "not_used" }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def truncated?(node)
|
|
228
|
+
node["children_count"].to_i.positive? || Array(node["children"]).any? do |child|
|
|
229
|
+
child.is_a?(Hash) && truncated?(child)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def fingerprint(body)
|
|
234
|
+
# Non-actionable AX text can be intrinsically volatile (Finder's free-space status changes
|
|
235
|
+
# when the proposal log is written). Revisions bind scope, coverage, and every offered target,
|
|
236
|
+
# while the full tree remains available for inspection and progressive traversal.
|
|
237
|
+
semantic = without_refs(body.except("snapshot_id", "tree"))
|
|
238
|
+
Digest::SHA256.hexdigest(JSON.generate(Observation.canonical(semantic)))[0, 24]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def without_refs(value)
|
|
242
|
+
case value
|
|
243
|
+
when Hash
|
|
244
|
+
value.each_with_object({}) do |(key, child), stripped|
|
|
245
|
+
stripped[key] = without_refs(child) unless %w[ref ref_id].include?(key)
|
|
246
|
+
end
|
|
247
|
+
when Array then value.map { |child| without_refs(child) }
|
|
248
|
+
else value
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Wrangle
|
|
6
|
+
# Deterministic permission policy. A provider may assess risk, but cannot make this gate weaker.
|
|
7
|
+
class DesktopPolicy
|
|
8
|
+
CREDENTIAL = /password|passcode|verification code|one[- ]time|security code|api key|secret|token/i
|
|
9
|
+
CONSEQUENCE = /\b(send|submit|delete|remove|trash|erase|buy|purchase|pay|confirm|save|allow|
|
|
10
|
+
install|quit|sign\s+out)\b/ix
|
|
11
|
+
CONSEQUENTIAL_OPERATIONS = %w[TOGGLE].freeze
|
|
12
|
+
WINDOW_CONTROL = /\A(?:close|close window|close all|quit(?: .*)?|minimize|zoom|full ?screen)\z/i
|
|
13
|
+
WINDOW_SUBROLES = %w[closebutton minimizebutton zoombutton fullscreenbutton].freeze
|
|
14
|
+
|
|
15
|
+
Assessment = Data.define(:classification, :consequential, :permitted, :reason) do
|
|
16
|
+
def to_h
|
|
17
|
+
{ "classification" => classification, "consequential" => consequential,
|
|
18
|
+
"permitted" => permitted, "reason" => reason }.compact
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def assess(scope:, candidate:, operation:)
|
|
23
|
+
label = candidate["label"].to_s
|
|
24
|
+
if operation == "SET_TEXT" && label.match?(CREDENTIAL)
|
|
25
|
+
return Assessment.new(classification: "credential_handoff", consequential: true, permitted: false,
|
|
26
|
+
reason: "Credentials must be entered through password-manager or SSO UI")
|
|
27
|
+
end
|
|
28
|
+
if operation == "PRESS" && window_control?(candidate, label)
|
|
29
|
+
return Assessment.new(classification: "scope_violation", consequential: true, permitted: false,
|
|
30
|
+
reason: "Wrangle never closes or manages a handed-over root window")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
consequential = CONSEQUENTIAL_OPERATIONS.include?(operation) ||
|
|
34
|
+
(operation == "PRESS" && label.match?(CONSEQUENCE))
|
|
35
|
+
reason = "#{scope.app} action #{label.inspect} may have an external or persistent consequence" if consequential
|
|
36
|
+
Assessment.new(classification: consequential ? "consequential" : "standard",
|
|
37
|
+
consequential:, permitted: true, reason:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def window_control?(candidate, label)
|
|
43
|
+
subrole = candidate["subrole"].to_s.delete_prefix("AX").downcase
|
|
44
|
+
WINDOW_SUBROLES.include?(subrole) || label.match?(WINDOW_CONTROL)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wrangle
|
|
4
|
+
# Escalates a bounded AX skeleton to a deeper root-window observation before an autonomous action.
|
|
5
|
+
# The provider must re-decide from the completed state; target choices never cross this boundary.
|
|
6
|
+
module DesktopProgressiveObservation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def escalate_observation?
|
|
10
|
+
return false if @view
|
|
11
|
+
|
|
12
|
+
previous = current
|
|
13
|
+
started = monotonic
|
|
14
|
+
@observation = @driver.observe(@scope, skeleton: false)
|
|
15
|
+
@full_observation = true
|
|
16
|
+
@proposals.clear
|
|
17
|
+
fields = {
|
|
18
|
+
"scope_id" => @scope.id, "revision" => @observation["revision"],
|
|
19
|
+
"previous_revision" => previous["revision"], "complete" => @observation["complete"],
|
|
20
|
+
"candidates" => @observation["candidates"].length,
|
|
21
|
+
"ambiguous_actions" => @observation.dig("coverage", "ambiguous_action_count"),
|
|
22
|
+
"elapsed_ms" => elapsed_ms(started)
|
|
23
|
+
}
|
|
24
|
+
@log&.record("observe_escalated", fields)
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fresh_observation
|
|
29
|
+
return @driver.drill(@scope, **@view) if @view
|
|
30
|
+
|
|
31
|
+
@driver.observe(@scope, skeleton: !@full_observation)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "desktop_observation"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Wrangle
|
|
9
|
+
# Validates and binds one typed action to one semantic desktop revision.
|
|
10
|
+
class DesktopProposal
|
|
11
|
+
TTL = 300
|
|
12
|
+
MAX_TEXT = 2000
|
|
13
|
+
|
|
14
|
+
def self.build(scope:, observation:, request:, policy:, created_at:)
|
|
15
|
+
index = request["ref"]
|
|
16
|
+
candidate = candidate(observation, index)
|
|
17
|
+
operation = request["operation"] || infer_operation(candidate)
|
|
18
|
+
unless candidate["operations"].include?(operation) && operation != "DRILL"
|
|
19
|
+
raise ArgumentError, "#{operation.inspect} was not offered for action ##{index.inspect}"
|
|
20
|
+
end
|
|
21
|
+
unless DesktopObservation.action_unambiguous?(observation["candidates"], candidate, operation)
|
|
22
|
+
raise ArgumentError, "#{operation.inspect} for action ##{index.inspect} matches multiple visible candidates"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
validate_text!(operation, request["text"])
|
|
26
|
+
assessment = policy.assess(scope:, candidate:, operation:)
|
|
27
|
+
raise PolicyDenied, assessment.reason unless assessment.permitted
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
"id" => SecureRandom.hex(12), "scope_id" => scope.id, "revision" => observation["revision"],
|
|
31
|
+
"candidate" => candidate, "number" => index, "operation" => operation, "text" => request["text"],
|
|
32
|
+
"policy" => assessment.to_h, "created_at" => created_at
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.compact(proposal)
|
|
37
|
+
text = proposal["text"]
|
|
38
|
+
{
|
|
39
|
+
"schema" => "wrangle.proposal.v1", "proposal_id" => proposal["id"],
|
|
40
|
+
"scope_id" => proposal["scope_id"], "revision" => proposal["revision"],
|
|
41
|
+
"action" => { "ref" => proposal["number"], "operation" => proposal["operation"],
|
|
42
|
+
"role" => proposal.dig("candidate", "role"),
|
|
43
|
+
"label" => proposal.dig("candidate", "label"),
|
|
44
|
+
"text" => text && { "source" => "explicit", "characters" => text.length } }.compact,
|
|
45
|
+
"policy" => proposal["policy"]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.candidate(observation, index)
|
|
50
|
+
candidates = observation["candidates"]
|
|
51
|
+
unless index.is_a?(Integer) && index.positive? && index <= candidates.length
|
|
52
|
+
raise ArgumentError, "No action ##{index.inspect} in the current desktop observation"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
candidates[index - 1]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.infer_operation(candidate)
|
|
59
|
+
actions = candidate["operations"] - ["DRILL"]
|
|
60
|
+
raise ArgumentError, "Specify one of #{actions.join(", ")}" unless actions.one?
|
|
61
|
+
|
|
62
|
+
actions.first
|
|
63
|
+
end
|
|
64
|
+
private_class_method :infer_operation
|
|
65
|
+
|
|
66
|
+
def self.validate_text!(operation, text)
|
|
67
|
+
raise ArgumentError, "Text is valid only for SET_TEXT" if operation != "SET_TEXT" && !text.nil?
|
|
68
|
+
return unless operation == "SET_TEXT"
|
|
69
|
+
return if text.is_a?(String) && !text.empty? && text.length <= MAX_TEXT && !text.include?("\0")
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "SET_TEXT requires an exact non-empty string of at most #{MAX_TEXT} characters"
|
|
72
|
+
end
|
|
73
|
+
private_class_method :validate_text!
|
|
74
|
+
end
|
|
75
|
+
end
|