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,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
|
|
11
|
+
module Wrangle
|
|
12
|
+
# Cross-session exclusive ownership for root surfaces.
|
|
13
|
+
class ScopeRegistry
|
|
14
|
+
Lease = Data.define(:path, :token, :mode)
|
|
15
|
+
Dispatch = Data.define(:path, :token)
|
|
16
|
+
|
|
17
|
+
def initialize(root: nil)
|
|
18
|
+
home = ENV["WRANGLE_HOME"] || File.join(Dir.home, ".wrangle")
|
|
19
|
+
@root = root || File.join(home, "scopes")
|
|
20
|
+
FileUtils.mkdir_p(@root, mode: 0o700)
|
|
21
|
+
File.chmod(0o700, @root)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def acquire(scope, mode: "exclusive")
|
|
25
|
+
valid = %w[exclusive cooperative].include?(mode)
|
|
26
|
+
raise ArgumentError, "Concurrency must be exclusive or cooperative" unless valid
|
|
27
|
+
|
|
28
|
+
ensure_no_unresolved_dispatch!(scope)
|
|
29
|
+
return Lease.new(path: nil, token: nil, mode:) if mode == "cooperative"
|
|
30
|
+
|
|
31
|
+
token = SecureRandom.hex(16)
|
|
32
|
+
path = path_for(scope)
|
|
33
|
+
payload = { "pid" => Process.pid, "token" => token, "scope_id" => scope.id,
|
|
34
|
+
"root" => scope.root, "driver" => "macos" }
|
|
35
|
+
create(path, payload)
|
|
36
|
+
Lease.new(path:, token:, mode:)
|
|
37
|
+
rescue Errno::EEXIST
|
|
38
|
+
reclaim(path) ? retry : raise(ScopeBusy, "Root surface #{scope.root} is held by another exclusive session")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def release(lease)
|
|
42
|
+
return unless lease&.path && File.file?(lease.path)
|
|
43
|
+
|
|
44
|
+
stored = read(lease.path)
|
|
45
|
+
File.unlink(lease.path) if stored["token"] == lease.token
|
|
46
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A durable marker crosses the only dangerous gap: process death after dispatch begins but before
|
|
51
|
+
# a receipt exists. It contains no labels or text, and is never reclaimed merely because its PID died.
|
|
52
|
+
def begin_dispatch(scope, proposal_id:, revision:, operation:)
|
|
53
|
+
ensure_no_unresolved_dispatch!(scope)
|
|
54
|
+
token = SecureRandom.hex(16)
|
|
55
|
+
path = dispatch_path_for(scope)
|
|
56
|
+
payload = { "pid" => Process.pid, "token" => token, "scope_id" => scope.id,
|
|
57
|
+
"proposal_id" => proposal_id, "revision" => revision, "operation" => operation,
|
|
58
|
+
"started_at" => Time.now.utc.iso8601(3) }
|
|
59
|
+
create(path, payload)
|
|
60
|
+
Dispatch.new(path:, token:)
|
|
61
|
+
rescue Errno::EEXIST
|
|
62
|
+
raise DeliveryUnknown, unresolved_dispatch_message
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finish_dispatch(dispatch)
|
|
66
|
+
return unless dispatch&.path
|
|
67
|
+
|
|
68
|
+
stat = File.lstat(dispatch.path)
|
|
69
|
+
raise DeliveryUnknown, unresolved_dispatch_message unless stat.file? && !stat.symlink?
|
|
70
|
+
|
|
71
|
+
stored = read(dispatch.path)
|
|
72
|
+
raise DeliveryUnknown, unresolved_dispatch_message unless stored["token"] == dispatch.token
|
|
73
|
+
|
|
74
|
+
File.unlink(dispatch.path)
|
|
75
|
+
sync_root
|
|
76
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
77
|
+
raise DeliveryUnknown, unresolved_dispatch_message
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def path_for(scope) = File.join(@root, "#{scope_digest(scope)}.json")
|
|
83
|
+
def dispatch_path_for(scope) = File.join(@root, "#{scope_digest(scope)}.dispatch.json")
|
|
84
|
+
|
|
85
|
+
def scope_digest(scope)
|
|
86
|
+
identity = ["macos", scope.pid, scope.process_instance, scope.root].join("\0")
|
|
87
|
+
Digest::SHA256.hexdigest(identity)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def create(path, payload)
|
|
91
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
92
|
+
file.write(JSON.generate(payload))
|
|
93
|
+
file.flush
|
|
94
|
+
file.fsync
|
|
95
|
+
end
|
|
96
|
+
sync_root
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_no_unresolved_dispatch!(scope)
|
|
100
|
+
path = dispatch_path_for(scope)
|
|
101
|
+
return unless File.exist?(path) || File.symlink?(path)
|
|
102
|
+
|
|
103
|
+
stat = File.lstat(path)
|
|
104
|
+
raise DeliveryUnknown, unresolved_dispatch_message unless stat.file? && !stat.symlink?
|
|
105
|
+
|
|
106
|
+
read(path)
|
|
107
|
+
raise DeliveryUnknown, unresolved_dispatch_message
|
|
108
|
+
rescue Errno::ENOENT
|
|
109
|
+
nil
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
raise DeliveryUnknown, unresolved_dispatch_message
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def unresolved_dispatch_message
|
|
115
|
+
"A previous action may have been delivered; inspect the application before starting another task"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def sync_root
|
|
119
|
+
File.open(@root, File::RDONLY, &:fsync)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def reclaim(path)
|
|
123
|
+
stat = File.lstat(path)
|
|
124
|
+
raise ScopeBusy, "Scope lease is not a regular owner-only file" unless stat.file? && !stat.symlink?
|
|
125
|
+
|
|
126
|
+
stored = read(path)
|
|
127
|
+
return false if process_alive?(stored["pid"])
|
|
128
|
+
|
|
129
|
+
File.unlink(path)
|
|
130
|
+
true
|
|
131
|
+
rescue Errno::ENOENT
|
|
132
|
+
true
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
raise ScopeBusy, "Scope lease is unreadable; remove #{path} after checking for a live session"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def read(path)
|
|
138
|
+
JSON.parse(File.read(path, 4096))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_alive?(pid)
|
|
142
|
+
return false unless pid.is_a?(Integer) && pid.positive?
|
|
143
|
+
|
|
144
|
+
Process.kill(0, pid)
|
|
145
|
+
true
|
|
146
|
+
rescue Errno::ESRCH
|
|
147
|
+
false
|
|
148
|
+
rescue Errno::EPERM
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -6,6 +6,7 @@ require "socket"
|
|
|
6
6
|
|
|
7
7
|
require_relative "run_loop"
|
|
8
8
|
require_relative "safari"
|
|
9
|
+
require_relative "timing"
|
|
9
10
|
|
|
10
11
|
module Wrangle
|
|
11
12
|
# A Safari session that outlives a single command.
|
|
@@ -37,11 +38,12 @@ module Wrangle
|
|
|
37
38
|
|
|
38
39
|
# `session` is injectable so the protocol, the diffing, and the refusal shapes can be tested
|
|
39
40
|
# against a fake bridge instead of a browser.
|
|
40
|
-
def initialize(socket_path, options, session: nil, jev: nil)
|
|
41
|
+
def initialize(socket_path, options, session: nil, jev: nil, timing: Timing)
|
|
41
42
|
@socket_path = socket_path
|
|
42
43
|
@options = options
|
|
43
44
|
@session = session
|
|
44
45
|
@jev = jev
|
|
46
|
+
@timing = timing
|
|
45
47
|
@page = nil
|
|
46
48
|
@acted = 0
|
|
47
49
|
@history = []
|
|
@@ -103,7 +105,7 @@ module Wrangle
|
|
|
103
105
|
def watch(thinking)
|
|
104
106
|
seen = @page && @page["fingerprint"]
|
|
105
107
|
while thinking.alive?
|
|
106
|
-
|
|
108
|
+
pause(STEADY_POLL)
|
|
107
109
|
break unless thinking.alive?
|
|
108
110
|
|
|
109
111
|
looked = @session.observe
|
|
@@ -156,7 +158,7 @@ module Wrangle
|
|
|
156
158
|
deadline = now + budget
|
|
157
159
|
loop do
|
|
158
160
|
before = @page["fingerprint"]
|
|
159
|
-
|
|
161
|
+
pause(STEADY_POLL)
|
|
160
162
|
@page = @session.observe
|
|
161
163
|
return @page if @page["fingerprint"] == before || now >= deadline
|
|
162
164
|
end
|
|
@@ -181,10 +183,10 @@ module Wrangle
|
|
|
181
183
|
if @options["window_id"]
|
|
182
184
|
raise ArgumentError, "The MCP backend cannot attach to an existing window" if mcp?
|
|
183
185
|
|
|
184
|
-
Safari.attach(window_id: @options["window_id"], display: @options["display"])
|
|
186
|
+
Safari.attach(window_id: @options["window_id"], display: @options["display"], timing: @timing)
|
|
185
187
|
else
|
|
186
188
|
Safari.open(@options.fetch("url"),
|
|
187
|
-
display: @options["display"], bounds: @options["bounds"],
|
|
189
|
+
display: @options["display"], bounds: @options["bounds"], timing: @timing,
|
|
188
190
|
**(mcp? ? { bridge: McpBridge.new } : {}))
|
|
189
191
|
end
|
|
190
192
|
end
|
|
@@ -290,7 +292,7 @@ module Wrangle
|
|
|
290
292
|
stable = 0
|
|
291
293
|
moved = departed?(baseline, page)
|
|
292
294
|
until now >= deadline || (stable >= STABLE_ROUNDS && (moved || now >= floor))
|
|
293
|
-
|
|
295
|
+
pause(SETTLE_POLL)
|
|
294
296
|
nxt = @session.observe
|
|
295
297
|
stable = nxt["fingerprint"] == page["fingerprint"] ? stable + 1 : 0
|
|
296
298
|
moved ||= departed?(baseline, nxt)
|
|
@@ -382,7 +384,7 @@ module Wrangle
|
|
|
382
384
|
expect = request["expect"] && Regexp.new(request["expect"], Regexp::IGNORECASE)
|
|
383
385
|
plan = Array(request["plan"]).filter_map { |goal| presence(goal) }
|
|
384
386
|
plan = [request["goal"]] if plan.empty?
|
|
385
|
-
summarise(request, RunLoop.new(self, request, expect).run(plan), expect)
|
|
387
|
+
summarise(request, RunLoop.new(self, request, expect, timing: @timing).run(plan), expect)
|
|
386
388
|
end
|
|
387
389
|
|
|
388
390
|
def summarise(request, steps, expect)
|
|
@@ -399,7 +401,8 @@ module Wrangle
|
|
|
399
401
|
|
|
400
402
|
def presence(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
401
403
|
|
|
402
|
-
def now =
|
|
404
|
+
def now = @timing.now
|
|
405
|
+
def pause(seconds) = @timing.sleep(seconds)
|
|
403
406
|
|
|
404
407
|
def shutdown
|
|
405
408
|
begin
|
|
@@ -407,7 +410,10 @@ module Wrangle
|
|
|
407
410
|
rescue Wrangle::Error
|
|
408
411
|
nil # A poisoned session refuses to close its window. That refusal is the correct outcome.
|
|
409
412
|
end
|
|
410
|
-
|
|
413
|
+
return unless @socket_path
|
|
414
|
+
|
|
415
|
+
FileUtils.rm_f(@socket_path)
|
|
416
|
+
FileUtils.rm_f("#{@socket_path}.pid")
|
|
411
417
|
end
|
|
412
418
|
end
|
|
413
419
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "macos_driver"
|
|
7
|
+
require_relative "macos_helper"
|
|
8
|
+
|
|
9
|
+
module Wrangle
|
|
10
|
+
# Binds a Tart VM name to one guest boot generation. Every guest-helper request verifies the VM
|
|
11
|
+
# before and after transport, so a stop, reboot, replacement, or guest-agent reconnect loses scope
|
|
12
|
+
# instead of silently attaching to a new desktop.
|
|
13
|
+
class TartVM
|
|
14
|
+
NAME = /\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/
|
|
15
|
+
DEFAULT_TART = ENV.fetch("WRANGLE_TART", "tart")
|
|
16
|
+
BOOT_COMMAND = %w[/usr/sbin/sysctl -n kern.boottime].freeze
|
|
17
|
+
BOOT_PATTERN = /\bsec = (\d+), usec = (\d+)\b/
|
|
18
|
+
TIMEOUT = 30
|
|
19
|
+
|
|
20
|
+
attr_reader :name, :instance
|
|
21
|
+
|
|
22
|
+
def initialize(name:, tart: DEFAULT_TART, runner_factory: nil, environment: ENV)
|
|
23
|
+
raise ArgumentError, "A valid Tart VM name is required" unless name.is_a?(String) && name.match?(NAME)
|
|
24
|
+
raise ArgumentError, "A Tart executable is required" if tart.to_s.empty? || tart.to_s.include?("\0")
|
|
25
|
+
|
|
26
|
+
@name = name
|
|
27
|
+
@tart = tart
|
|
28
|
+
@environment = filtered_environment(environment)
|
|
29
|
+
@runner_factory = runner_factory || method(:runner)
|
|
30
|
+
@instance = capture_instance
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def around
|
|
34
|
+
verify!
|
|
35
|
+
result = yield
|
|
36
|
+
verify!
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verify!
|
|
41
|
+
actual = capture_instance
|
|
42
|
+
return true if actual == instance
|
|
43
|
+
|
|
44
|
+
raise DriverRefusal.new(
|
|
45
|
+
"scope_changed", "The scoped Tart guest restarted or was replaced",
|
|
46
|
+
delivery: "not_delivered", retry_disposition: "unsafe"
|
|
47
|
+
)
|
|
48
|
+
rescue DriverRefusal
|
|
49
|
+
raise
|
|
50
|
+
rescue DriverError
|
|
51
|
+
raise DriverRefusal.new(
|
|
52
|
+
"scope_changed", "The scoped Tart guest is no longer available",
|
|
53
|
+
delivery: "not_delivered", retry_disposition: "unsafe"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def capture_instance
|
|
60
|
+
selected = inventory.select { |record| record["Name"] == name }
|
|
61
|
+
unless selected.one? && selected.first["Running"] == true
|
|
62
|
+
raise DriverUnavailable, "The exact Tart guest is not running"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
boot = run(@tart, "exec", name, *BOOT_COMMAND)
|
|
66
|
+
match = BOOT_PATTERN.match(boot)
|
|
67
|
+
raise DriverError, "The Tart guest returned no stable boot identity" unless match
|
|
68
|
+
|
|
69
|
+
"tart-guest-v1:#{name}:#{match[1]}:#{match[2]}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inventory
|
|
73
|
+
parsed = JSON.parse(run(@tart, "list", "--format", "json"))
|
|
74
|
+
valid = parsed.is_a?(Array) && parsed.all?(Hash)
|
|
75
|
+
raise DriverError, "Tart returned an invalid VM inventory" unless valid
|
|
76
|
+
|
|
77
|
+
parsed
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
raise DriverError, "Tart returned invalid VM inventory JSON"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run(*command)
|
|
83
|
+
result = @runner_factory.call(command).call
|
|
84
|
+
raise DriverUnavailable, "Tart guest transport is unavailable" unless result.status.success?
|
|
85
|
+
|
|
86
|
+
result.stdout
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def runner(command)
|
|
90
|
+
MacOSHelper::Runner.new(command:, environment: @environment, timeout: TIMEOUT)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def filtered_environment(source)
|
|
94
|
+
MacOSHelper::SAFE_ENV.each_with_object({}) { |key, kept| kept[key] = source[key] if source.key?(key) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Presents the normal MacOSHelper contract over Tart's argument-only guest-agent transport.
|
|
99
|
+
class TartGuestHelper
|
|
100
|
+
def initialize(vm_guard:, guest_app:, helper:)
|
|
101
|
+
@vm_guard = vm_guard
|
|
102
|
+
@guest_app = guest_app
|
|
103
|
+
@helper = helper
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ping = guarded { @helper.ping }
|
|
107
|
+
def displays = guarded { @helper.displays }
|
|
108
|
+
|
|
109
|
+
def windows(app:, titles: false)
|
|
110
|
+
validate_app!(app)
|
|
111
|
+
guarded do
|
|
112
|
+
windows = @helper.windows(app:, titles:)
|
|
113
|
+
unless windows.is_a?(Array) && windows.all?(Hash)
|
|
114
|
+
raise DriverError, "The guest helper returned an invalid window inventory"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
windows.map do |window|
|
|
118
|
+
process = window["process_instance"]
|
|
119
|
+
process.is_a?(String) ? window.merge("process_instance" => scoped_process(process)) : window
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def enable_accessibility(pid:, process_instance:)
|
|
125
|
+
guarded { @helper.enable_accessibility(pid:, process_instance: guest_process(process_instance)) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def snapshot(pid:, process_instance:, window_id:, bounds:, root_target: nil, max_depth: 18)
|
|
129
|
+
guarded do
|
|
130
|
+
@helper.snapshot(
|
|
131
|
+
pid:, process_instance: guest_process(process_instance), window_id:, bounds:, root_target:, max_depth:
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def execute(*)
|
|
137
|
+
raise DriverRefusal.new(
|
|
138
|
+
"read_only", "The Tart guest prototype does not dispatch actions",
|
|
139
|
+
delivery: "not_delivered", retry_disposition: "unsafe"
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def guarded(&) = @vm_guard.around(&)
|
|
146
|
+
|
|
147
|
+
def validate_app!(app)
|
|
148
|
+
return if app.to_s.casecmp?(@guest_app)
|
|
149
|
+
|
|
150
|
+
raise ScopeLost, "The Tart guest driver cannot leave its explicit application scope"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def scoped_process(process_instance) = "#{@vm_guard.instance}/#{process_instance}"
|
|
154
|
+
|
|
155
|
+
def guest_process(process_instance)
|
|
156
|
+
prefix = "#{@vm_guard.instance}/"
|
|
157
|
+
unless process_instance.is_a?(String) && process_instance.start_with?(prefix)
|
|
158
|
+
raise ScopeLost, "The process does not belong to the scoped Tart guest"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
process_instance.delete_prefix(prefix)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Read-only exact-window driver for one explicitly named Tart VM and one explicitly named guest
|
|
166
|
+
# application. It deliberately exposes no mutation path and never falls back to the host Tart view.
|
|
167
|
+
class TartGuestDriver
|
|
168
|
+
DEFAULT_HELPER = ENV.fetch(
|
|
169
|
+
"WRANGLE_TART_GUEST_HELPER",
|
|
170
|
+
"/Users/admin/Applications/Wrangle Helper.app/Contents/MacOS/wrangle-macos-helper"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
attr_reader :vm_name, :vm_instance, :guest_app
|
|
174
|
+
|
|
175
|
+
def initialize(vm_name:, guest_app:, tart: TartVM::DEFAULT_TART, guest_helper: DEFAULT_HELPER,
|
|
176
|
+
guard: nil, helper: nil, driver: nil)
|
|
177
|
+
validate_helper!(guest_helper)
|
|
178
|
+
unless vm_name.is_a?(String) && vm_name.match?(TartVM::NAME)
|
|
179
|
+
raise ArgumentError, "A valid Tart VM name is required"
|
|
180
|
+
end
|
|
181
|
+
raise ArgumentError, "An explicit Tart guest application is required" if guest_app.to_s.strip.empty?
|
|
182
|
+
|
|
183
|
+
@guard = guard || TartVM.new(name: vm_name, tart:)
|
|
184
|
+
guest = helper || MacOSHelper.new(command: [tart, "exec", vm_name, guest_helper])
|
|
185
|
+
guarded = TartGuestHelper.new(vm_guard: @guard, guest_app:, helper: guest)
|
|
186
|
+
@driver = driver || MacOSDriver.new(helper: guarded, observation_driver: "tart_guest", read_only: true)
|
|
187
|
+
@vm_name = vm_name
|
|
188
|
+
@vm_instance = @guard.instance
|
|
189
|
+
@guest_app = guest_app
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def doctor
|
|
193
|
+
result = @driver.doctor
|
|
194
|
+
capabilities = Hash(result["capabilities"]).merge("ax_dispatch" => "unavailable_read_only")
|
|
195
|
+
result.merge(
|
|
196
|
+
"environment" => "tart_guest", "vm" => vm_name, "guest_app" => guest_app,
|
|
197
|
+
"read_only" => true, "capabilities" => capabilities
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def windows(app:, titles: false)
|
|
202
|
+
validate_app!(app)
|
|
203
|
+
@driver.windows(app:, titles:)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def displays = @driver.displays
|
|
207
|
+
|
|
208
|
+
def attach(window_id:, app:)
|
|
209
|
+
validate_app!(app)
|
|
210
|
+
@driver.attach(window_id:, app:).tap { |scope| validate_scope!(scope) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def observe(scope, skeleton: true)
|
|
214
|
+
validate_scope!(scope)
|
|
215
|
+
@driver.observe(scope, skeleton:)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def drill(scope, ref:, snapshot_id:)
|
|
219
|
+
validate_scope!(scope)
|
|
220
|
+
@driver.drill(scope, ref:, snapshot_id:)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def execute(*)
|
|
224
|
+
{
|
|
225
|
+
"dispatch" => "not_delivered", "code" => "read_only",
|
|
226
|
+
"message" => "The Tart guest prototype does not dispatch actions", "retry_safe" => false
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def close = @driver.close
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def validate_scope!(scope)
|
|
235
|
+
return if scope.process_instance.to_s.start_with?("#{vm_instance}/")
|
|
236
|
+
|
|
237
|
+
raise ScopeLost, "The Tart guest scope is stale or belongs to another driver"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_helper!(path)
|
|
241
|
+
valid = path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && !path.include?("\n")
|
|
242
|
+
raise ArgumentError, "An absolute Tart guest helper path is required" unless valid
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def validate_app!(app)
|
|
246
|
+
return if app.to_s.casecmp?(guest_app)
|
|
247
|
+
|
|
248
|
+
raise ScopeLost, "The Tart guest driver cannot leave its explicit application scope"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wrangle
|
|
4
|
+
# Real monotonic time and sleeping are defaults, not hard-coded dependencies. Tests and embedded
|
|
5
|
+
# callers can supply the same two-method interface to advance deadlines deterministically.
|
|
6
|
+
module Timing
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
def sleep(seconds) = Kernel.sleep(seconds)
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/wrangle/version.rb
CHANGED
data/lib/wrangle.rb
CHANGED
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "wrangle/version"
|
|
4
4
|
require_relative "wrangle/errors"
|
|
5
|
+
require_relative "wrangle/timing"
|
|
5
6
|
require_relative "wrangle/observation"
|
|
7
|
+
require_relative "wrangle/desktop_observation"
|
|
8
|
+
require_relative "wrangle/desktop_policy"
|
|
9
|
+
require_relative "wrangle/decision_provider"
|
|
10
|
+
require_relative "wrangle/provider_qualification"
|
|
11
|
+
require_relative "wrangle/provider_factory"
|
|
12
|
+
require_relative "wrangle/desktop_decider"
|
|
13
|
+
require_relative "wrangle/desktop_autonomy"
|
|
14
|
+
require_relative "wrangle/desktop_session_autonomy"
|
|
15
|
+
require_relative "wrangle/desktop_dispatch"
|
|
16
|
+
require_relative "wrangle/desktop_effect"
|
|
17
|
+
require_relative "wrangle/desktop_proposal"
|
|
18
|
+
require_relative "wrangle/event_log"
|
|
19
|
+
require_relative "wrangle/scope_registry"
|
|
20
|
+
require_relative "wrangle/macos_helper"
|
|
21
|
+
require_relative "wrangle/macos_driver"
|
|
22
|
+
require_relative "wrangle/tart_guest_driver"
|
|
6
23
|
require_relative "wrangle/jxa_bridge"
|
|
7
24
|
require_relative "wrangle/mcp_bridge"
|
|
8
25
|
require_relative "wrangle/safari"
|
|
@@ -10,8 +27,10 @@ require_relative "wrangle/jev"
|
|
|
10
27
|
require_relative "wrangle/action_space"
|
|
11
28
|
require_relative "wrangle/decider"
|
|
12
29
|
require_relative "wrangle/session_server"
|
|
30
|
+
require_relative "wrangle/desktop_session_server"
|
|
31
|
+
require_relative "wrangle/desktop_task"
|
|
13
32
|
|
|
14
|
-
#
|
|
33
|
+
# Scoped computer use for macOS: one Safari window or one app window, and no more than that.
|
|
15
34
|
#
|
|
16
35
|
# Wrangle drives an ordinary Safari window through Apple Events. There is no automation session and
|
|
17
36
|
# no extension, so the window stays a real one the user can see, keep, and take back at any moment.
|
data/skills/wrangle/SKILL.md
CHANGED
|
@@ -1,17 +1,50 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wrangle
|
|
3
|
-
description:
|
|
4
|
-
compatibility: macOS with
|
|
3
|
+
description: Use a real, scoped macOS app or Safari window for requests such as "Check the #notifications channel in Boehs Slack", "use my browser", or "/wrangle". Prefer the computer tool when available; otherwise use the Safari CLI. Not for static public pages that normal HTTP can fetch.
|
|
4
|
+
compatibility: macOS with Wrangle on PATH. Desktop use requires the repository Pi computer extension, Jev credentials (or an explicitly configured alternative provider), and Accessibility permission. Safari additionally requires JavaScript from Apple Events and its one-time permission prompt.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Wrangle
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Wrangle lets an agent work inside one real window without taking over the rest of the computer.
|
|
10
|
+
|
|
11
|
+
## Natural desktop requests in Pi
|
|
12
|
+
|
|
13
|
+
When the `computer` tool is available, call it **once** with the complete natural goal and application
|
|
14
|
+
name. **Do not make the user learn Wrangle commands, window IDs, refs, proposal IDs, revisions,
|
|
15
|
+
receipts, AX terminology, or provider configuration.** Wrangle owns window selection and the bounded
|
|
16
|
+
read → typed decision → policy → action → verification loop.
|
|
17
|
+
|
|
18
|
+
For example, when the user says:
|
|
19
|
+
|
|
20
|
+
> Check the #notifications channel in Boehs Slack.
|
|
21
|
+
|
|
22
|
+
call `computer` once with that goal and `app: "Slack"`, then summarize the returned visible evidence.
|
|
23
|
+
Do not manually list, attach, observe, drill, find, preview, execute, or finish.
|
|
24
|
+
|
|
25
|
+
- A plain imperative authorizes necessary reversible navigation, not Send, Delete, purchases, or
|
|
26
|
+
settings changes. Consequential actions stop before delivery. This alpha cannot resume the bound
|
|
27
|
+
proposal, so explain the limitation and do not call `computer` again in a loop.
|
|
28
|
+
- Pass a literal only when it is exact non-secret text from the user's request. Never invent text.
|
|
29
|
+
- Never use shell commands during or after a computer task—not for debugging, checking the date, or
|
|
30
|
+
bypassing a refusal. Never substitute a raw accessibility helper or another computer-use tool.
|
|
31
|
+
- Wrangle stops at eight actions and three progressive drills. Failed or uncertain delivery is never
|
|
32
|
+
retried automatically.
|
|
33
|
+
- Say what happened in the app: “I checked #notifications…” rather than describing decisions,
|
|
34
|
+
candidates, drivers, or receipts.
|
|
35
|
+
- Wrangle releases control automatically on success or failure and **leaves the user's app window
|
|
36
|
+
open by design**. There is no separate finish call.
|
|
37
|
+
- If delivery is uncertain, say plainly that the action may or may not have happened and do not retry.
|
|
38
|
+
|
|
39
|
+
## Safari CLI
|
|
40
|
+
|
|
41
|
+
Use the CLI when the `computer` tool is unavailable or the task specifically targets Safari. Every
|
|
42
|
+
command below is a shell command.
|
|
10
43
|
|
|
11
44
|
**Never write a Ruby script for this.** The CLI is the entire interface. If you catch yourself
|
|
12
45
|
writing `require "wrangle"`, stop — use `wrangle observe` and `wrangle act` instead.
|
|
13
46
|
|
|
14
|
-
|
|
47
|
+
### The loop
|
|
15
48
|
|
|
16
49
|
```bash
|
|
17
50
|
wrangle open "https://example.com" --display 1 --settle 8 # start; prints the first observation
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wrangle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -10,10 +10,10 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: |
|
|
13
|
-
Wrangle
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
Wrangle gives an agent one exact application window or Safari tab and no more than that. Its
|
|
14
|
+
native macOS driver runs a bounded typed-decision loop with policy, fresh-target validation,
|
|
15
|
+
at-most-once dispatch, and independent effect verification. Safari uses Apple Events for fast
|
|
16
|
+
DOM-grounded actions. Losing scope ends the task rather than searching for a substitute.
|
|
17
17
|
email:
|
|
18
18
|
- ericboehs@gmail.com
|
|
19
19
|
executables:
|
|
@@ -28,17 +28,39 @@ files:
|
|
|
28
28
|
- lib/wrangle.rb
|
|
29
29
|
- lib/wrangle/action_space.rb
|
|
30
30
|
- lib/wrangle/decider.rb
|
|
31
|
+
- lib/wrangle/decision_provider.rb
|
|
32
|
+
- lib/wrangle/desktop_autonomy.rb
|
|
33
|
+
- lib/wrangle/desktop_decider.rb
|
|
34
|
+
- lib/wrangle/desktop_dispatch.rb
|
|
35
|
+
- lib/wrangle/desktop_effect.rb
|
|
36
|
+
- lib/wrangle/desktop_observation.rb
|
|
37
|
+
- lib/wrangle/desktop_policy.rb
|
|
38
|
+
- lib/wrangle/desktop_progressive_observation.rb
|
|
39
|
+
- lib/wrangle/desktop_proposal.rb
|
|
40
|
+
- lib/wrangle/desktop_session_autonomy.rb
|
|
41
|
+
- lib/wrangle/desktop_session_server.rb
|
|
42
|
+
- lib/wrangle/desktop_task.rb
|
|
31
43
|
- lib/wrangle/errors.rb
|
|
44
|
+
- lib/wrangle/event_log.rb
|
|
32
45
|
- lib/wrangle/jev.rb
|
|
33
46
|
- lib/wrangle/js/bridge.js
|
|
34
47
|
- lib/wrangle/js/page.js
|
|
35
48
|
- lib/wrangle/js/snapshot.js
|
|
36
49
|
- lib/wrangle/jxa_bridge.rb
|
|
50
|
+
- lib/wrangle/macos/helper.swift
|
|
51
|
+
- lib/wrangle/macos_driver.rb
|
|
52
|
+
- lib/wrangle/macos_helper.rb
|
|
37
53
|
- lib/wrangle/mcp_bridge.rb
|
|
38
54
|
- lib/wrangle/observation.rb
|
|
55
|
+
- lib/wrangle/provider_conformance.jsonl
|
|
56
|
+
- lib/wrangle/provider_factory.rb
|
|
57
|
+
- lib/wrangle/provider_qualification.rb
|
|
39
58
|
- lib/wrangle/run_loop.rb
|
|
40
59
|
- lib/wrangle/safari.rb
|
|
60
|
+
- lib/wrangle/scope_registry.rb
|
|
41
61
|
- lib/wrangle/session_server.rb
|
|
62
|
+
- lib/wrangle/tart_guest_driver.rb
|
|
63
|
+
- lib/wrangle/timing.rb
|
|
42
64
|
- lib/wrangle/version.rb
|
|
43
65
|
- skills/wrangle/SKILL.md
|
|
44
66
|
homepage: https://github.com/ericboehs/wrangle
|
|
@@ -63,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
63
85
|
- !ruby/object:Gem::Version
|
|
64
86
|
version: '0'
|
|
65
87
|
requirements: []
|
|
66
|
-
rubygems_version: 4.0.
|
|
88
|
+
rubygems_version: 4.0.20
|
|
67
89
|
specification_version: 4
|
|
68
|
-
summary:
|
|
90
|
+
summary: Scoped, policy-gated computer use for real macOS and Safari windows.
|
|
69
91
|
test_files: []
|