wrangle 0.1.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 +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE.txt +48 -0
- data/README.md +473 -0
- data/exe/wrangle +374 -0
- data/lib/wrangle/action_space.rb +67 -0
- data/lib/wrangle/decider.rb +167 -0
- data/lib/wrangle/errors.rb +53 -0
- data/lib/wrangle/jev.rb +87 -0
- data/lib/wrangle/js/bridge.js +390 -0
- data/lib/wrangle/js/page.js +127 -0
- data/lib/wrangle/js/snapshot.js +118 -0
- data/lib/wrangle/jxa_bridge.rb +194 -0
- data/lib/wrangle/mcp_bridge.rb +300 -0
- data/lib/wrangle/observation.rb +50 -0
- data/lib/wrangle/run_loop.rb +298 -0
- data/lib/wrangle/safari.rb +404 -0
- data/lib/wrangle/session_server.rb +447 -0
- data/lib/wrangle/version.rb +5 -0
- data/lib/wrangle.rb +19 -0
- data/skills/wrangle/SKILL.md +206 -0
- metadata +69 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Vendored from browser-use/jev-ultrafast (MIT, Copyright (c) 2026 Browser Use).
|
|
2
|
+
// Modified: the page cache global was renamed from __jevFast to __wrangle.
|
|
3
|
+
(() => {
|
|
4
|
+
if (!document.body) return null;
|
|
5
|
+
const cache = window.__wrangle ||= {ids:new WeakMap(), nodes:new Map(), next:1};
|
|
6
|
+
const identity = e => {
|
|
7
|
+
if (!cache.ids.has(e)) cache.ids.set(e,cache.next++);
|
|
8
|
+
const id=cache.ids.get(e); cache.nodes.set(id,e); return id;
|
|
9
|
+
};
|
|
10
|
+
for (const [id,e] of cache.nodes) if (!e.isConnected) cache.nodes.delete(id);
|
|
11
|
+
const safe = e => !['password','file','hidden'].includes(e.type);
|
|
12
|
+
const visible = e => !e.closest('[aria-hidden="true"],[inert]') &&
|
|
13
|
+
e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});
|
|
14
|
+
const name = (e,seen=new Set()) => {
|
|
15
|
+
if (!e || seen.has(e)) return '';
|
|
16
|
+
seen.add(e);
|
|
17
|
+
const referenced=(e.getAttribute('aria-labelledby')||'').split(/\s+/)
|
|
18
|
+
.map(id=>name(document.getElementById(id),seen)).filter(Boolean).join(' ');
|
|
19
|
+
return referenced || e.getAttribute('aria-label') ||
|
|
20
|
+
[...(e.labels||[])].map(l=>name(l,seen)).filter(Boolean).join(' ') ||
|
|
21
|
+
(['button','submit','reset'].includes(e.type) ? e.value : '') || e.getAttribute('alt') ||
|
|
22
|
+
(e.tagName==='INPUT' ? '' : [...e.childNodes].map(n=>n.nodeType===3 ? n.textContent :
|
|
23
|
+
n.nodeType===1 && n.getAttribute('aria-hidden')!=='true' ? name(n,seen) : '').join(' ').trim()) ||
|
|
24
|
+
e.getAttribute('title') || e.getAttribute('placeholder') || '';
|
|
25
|
+
};
|
|
26
|
+
const roles=['button','link','checkbox','radio','switch','tab','menuitem','menuitemradio',
|
|
27
|
+
'option','gridcell','combobox','textbox','searchbox','spinbutton'];
|
|
28
|
+
const selector='a[href],button,input,textarea,select,summary,[contenteditable="true"],'+
|
|
29
|
+
roles.map(role=>'[role="'+role+'"]').join(',');
|
|
30
|
+
const role = e => {
|
|
31
|
+
const explicit=e.getAttribute('role');
|
|
32
|
+
if (roles.includes(explicit)) return explicit;
|
|
33
|
+
if (e.tagName==='BUTTON' || e.tagName==='SUMMARY') return 'button';
|
|
34
|
+
if (e.tagName==='A') return 'link';
|
|
35
|
+
if (e.tagName==='SELECT') return 'combobox';
|
|
36
|
+
if (e.tagName==='TEXTAREA' || e.isContentEditable) return 'textbox';
|
|
37
|
+
if (e.tagName==='INPUT') {
|
|
38
|
+
if (['checkbox','radio'].includes(e.type)) return e.type;
|
|
39
|
+
if (['button','submit','reset','image'].includes(e.type)) return 'button';
|
|
40
|
+
if (e.type==='search') return 'searchbox';
|
|
41
|
+
if (e.type==='number') return 'spinbutton';
|
|
42
|
+
if (['text','email','url','tel'].includes(e.type)) return 'textbox';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
// Split by what each part proves, so a stale decision can say which of them moved rather than
|
|
47
|
+
// reporting that something, somewhere, did: `origin` is the document, `route` is the page within
|
|
48
|
+
// it, `view` is the viewport, and `form` is the state of every field.
|
|
49
|
+
//
|
|
50
|
+
// The route deliberately drops the query string. Sites rewrite it constantly to hold state — a
|
|
51
|
+
// flight form puts the whole itinerary in `?tfs=` and rewrites it on every keystroke — and
|
|
52
|
+
// comparing the full URL rejected decisions about controls that had not moved. A real navigation
|
|
53
|
+
// still shows up: the path changes, or the document does, or the nodes are rebuilt and the
|
|
54
|
+
// element's own identity changes with them.
|
|
55
|
+
cache.pageKey=()=>({origin:performance.timeOrigin,route:location.origin+location.pathname,view:[scrollX,scrollY,innerWidth,innerHeight],
|
|
56
|
+
form:[...document.querySelectorAll('input,textarea,select')].filter(safe)
|
|
57
|
+
.map(e=>[identity(e),e.value,e.checked,e.selectedIndex,e.disabled,e.readOnly])});
|
|
58
|
+
cache.guard=e=>{
|
|
59
|
+
if (!e?.isConnected || !visible(e)) return null;
|
|
60
|
+
const scope=e.closest('form,dialog,[role="dialog"],article,li,tr,[role="row"]') || e.parentElement;
|
|
61
|
+
return {self:[identity(e),role(e),name(e),e.value??null,e.checked??null,e.selectedIndex??null,
|
|
62
|
+
e.readOnly??null,e.matches(':disabled'),e.getAttribute('aria-disabled'),
|
|
63
|
+
e.getAttribute('aria-expanded'),e.getAttribute('aria-checked'),e.getAttribute('aria-selected'),
|
|
64
|
+
e.getAttribute('href')],scope:scope?.innerText?.slice(0,6000)||''};
|
|
65
|
+
};
|
|
66
|
+
const actions=[];
|
|
67
|
+
for (const e of document.querySelectorAll(selector)) {
|
|
68
|
+
if (!safe(e) || !visible(e) || e.matches(':disabled') || e.closest('[aria-disabled="true"]')) continue;
|
|
69
|
+
const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
|
|
70
|
+
if (!rname || r.width<=0 || r.height<=0 || x<0 || y<0 || x>=innerWidth || y>=innerHeight) continue;
|
|
71
|
+
if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
72
|
+
const base={node:identity(e),role:rname,label:name(e)||rname,
|
|
73
|
+
rect:{x:r.x,y:r.y,w:r.width,h:r.height}};
|
|
74
|
+
for (const key of ['checked','selected','expanded']) {
|
|
75
|
+
const value=e.getAttribute('aria-'+key);
|
|
76
|
+
if (value!==null) base[key]=value;
|
|
77
|
+
}
|
|
78
|
+
if (['checkbox','radio'].includes(e.type)) base.checked=String(e.checked);
|
|
79
|
+
if (e.tagName==='SELECT') {
|
|
80
|
+
for (const o of e.options) if (!o.selected && !o.disabled && !o.closest('optgroup[disabled]'))
|
|
81
|
+
actions.push({...base,kind:'select',value:o.value,
|
|
82
|
+
current_value:[...e.selectedOptions].map(o=>o.label).join(', '),label:base.label+' → '+o.label});
|
|
83
|
+
} else {
|
|
84
|
+
const editable=!e.readOnly && e.getAttribute('aria-readonly')!=='true' &&
|
|
85
|
+
(['textbox','searchbox','spinbutton'].includes(rname) ||
|
|
86
|
+
(rname==='combobox' && ['INPUT','TEXTAREA'].includes(e.tagName)));
|
|
87
|
+
const value='value' in e ? String(e.value) :
|
|
88
|
+
e.isContentEditable || rname==='combobox' ? e.innerText.trim() : '';
|
|
89
|
+
actions.push({...base,kind:editable?'fill':'click',value});
|
|
90
|
+
if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const words=[], walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);
|
|
94
|
+
const range=document.createRange(); let node,length=0;
|
|
95
|
+
while ((node=walker.nextNode()) && length<6000) {
|
|
96
|
+
const value=node.textContent.trim(), parent=node.parentElement;
|
|
97
|
+
if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
|
|
98
|
+
range.selectNodeContents(node); const r=range.getBoundingClientRect();
|
|
99
|
+
if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
|
|
100
|
+
words.push(value); length+=value.length;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const text=words.join('\n').slice(0,6000), height=document.documentElement.scrollHeight;
|
|
104
|
+
const page_key=cache.pageKey(), guards={};
|
|
105
|
+
for (const a of actions) if (!(a.node in guards)) guards[a.node]=cache.guard(cache.nodes.get(a.node));
|
|
106
|
+
// Compare meaning and identity. Geometry is always resolved and hit-tested just before input.
|
|
107
|
+
const semantics=actions.map(({rect,...action})=>action);
|
|
108
|
+
const marker=[performance.timeOrigin,location.href,scrollX,scrollY,innerWidth,innerHeight,
|
|
109
|
+
document.title,text,semantics,page_key.form];
|
|
110
|
+
const omitted_actions=Math.max(0,actions.length-250);
|
|
111
|
+
actions.splice(250);
|
|
112
|
+
actions.forEach((a,i)=>a.id='e'+(i+1));
|
|
113
|
+
if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
|
|
114
|
+
if (scrollY>0) actions.push({id:'scroll_up',kind:'scroll',label:'Scroll up',delta:-560});
|
|
115
|
+
actions.push({id:'wait',kind:'wait',label:'Wait for the page to update'});
|
|
116
|
+
return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
|
|
117
|
+
scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
|
|
118
|
+
})()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Wrangle
|
|
10
|
+
# A persistent `osascript -l JavaScript` process speaking newline-delimited JSON.
|
|
11
|
+
#
|
|
12
|
+
# Spawning osascript costs about 56 ms; a request to a process that is already running costs about
|
|
13
|
+
# 0.3 ms. Keeping one alive means the only cost left is the Apple Event itself, which is roughly
|
|
14
|
+
# 17 ms and does not care how much data it carries.
|
|
15
|
+
class JxaBridge
|
|
16
|
+
OSASCRIPT = ["/usr/bin/osascript", "-l", "JavaScript"].freeze
|
|
17
|
+
SCRIPTS = File.expand_path("js", __dir__)
|
|
18
|
+
MAX_LINE_BYTES = 4_000_000
|
|
19
|
+
STDERR_LINES = 50
|
|
20
|
+
|
|
21
|
+
EOF = :eof
|
|
22
|
+
INVALID = :invalid
|
|
23
|
+
OVERSIZED = :oversized
|
|
24
|
+
|
|
25
|
+
attr_reader :pid
|
|
26
|
+
|
|
27
|
+
def initialize(command: OSASCRIPT, script: File.join(SCRIPTS, "bridge.js"),
|
|
28
|
+
request_timeout: 30, startup_timeout: 20)
|
|
29
|
+
raise ArgumentError, "Bridge timeouts must be positive" unless [request_timeout, startup_timeout].min.positive?
|
|
30
|
+
|
|
31
|
+
@command = command
|
|
32
|
+
@script = script
|
|
33
|
+
@request_timeout = request_timeout
|
|
34
|
+
@startup_timeout = startup_timeout
|
|
35
|
+
@lock = Monitor.new # Monitor, not Mutex: start re-enters request.
|
|
36
|
+
@lines = Thread::Queue.new
|
|
37
|
+
@stderr = []
|
|
38
|
+
@next_id = 1
|
|
39
|
+
@started = false
|
|
40
|
+
@closed = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def running? = @wait&.alive? || false
|
|
44
|
+
|
|
45
|
+
def stderr = @stderr.dup
|
|
46
|
+
|
|
47
|
+
# Spawn the bridge, confirm it answers, and hand it the page scripts once.
|
|
48
|
+
#
|
|
49
|
+
# The scripts are sent at startup rather than per call. That keeps about 12 KB off every
|
|
50
|
+
# subsequent request and, more importantly, means the wire carries data and never program text.
|
|
51
|
+
def start
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
raise BridgeError, "Bridge is already started" if @started
|
|
54
|
+
raise BridgeError, "Bridge script is missing: #{@script}" unless File.file?(@script)
|
|
55
|
+
|
|
56
|
+
@started = true
|
|
57
|
+
spawn_process
|
|
58
|
+
ping = request("ping", timeout: @startup_timeout)
|
|
59
|
+
request("scripts", timeout: @startup_timeout,
|
|
60
|
+
page: File.read(File.join(SCRIPTS, "page.js")),
|
|
61
|
+
snapshot: File.read(File.join(SCRIPTS, "snapshot.js")))
|
|
62
|
+
ping
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Send one operation and return its validated value.
|
|
67
|
+
def request(op, timeout: nil, **params)
|
|
68
|
+
@lock.synchronize do
|
|
69
|
+
ensure_running
|
|
70
|
+
id = @next_id
|
|
71
|
+
@next_id += 1
|
|
72
|
+
begin
|
|
73
|
+
@stdin.write("#{JSON.generate(params.compact.merge(id: id, op: op))}\n")
|
|
74
|
+
rescue Errno::EPIPE, IOError
|
|
75
|
+
raise BridgeError, "The bridge is no longer accepting requests"
|
|
76
|
+
end
|
|
77
|
+
response = receive(id, timeout || @request_timeout)
|
|
78
|
+
raise BridgeCallError.new(response["code"], response["error"]) unless response["ok"]
|
|
79
|
+
|
|
80
|
+
response.fetch("value")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Run one code-owned page request inside the scoped tab and decode its JSON reply.
|
|
85
|
+
#
|
|
86
|
+
# The request is encoded here and passed into a fixed page function as a single JSON argument.
|
|
87
|
+
# It is never interpolated into the program's structure.
|
|
88
|
+
def evaluate(scope, page_request, verify: false, timeout: nil)
|
|
89
|
+
raise ArgumentError, "Bridge evaluation needs a scope and a request" unless scope.is_a?(Hash) &&
|
|
90
|
+
page_request.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
value = request("eval", scope: scope, payload: payload(page_request), verify: verify, timeout: timeout)
|
|
93
|
+
result = value["result"]
|
|
94
|
+
raise BridgeError, "Bridge evaluation returned no page result" unless result.is_a?(String)
|
|
95
|
+
|
|
96
|
+
decoded = begin
|
|
97
|
+
JSON.parse(result)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
raise BridgeError, "The page returned invalid JSON"
|
|
100
|
+
end
|
|
101
|
+
raise BridgeError, "The page returned an invalid result" unless decoded.is_a?(Hash) &&
|
|
102
|
+
decoded["status"].is_a?(String)
|
|
103
|
+
|
|
104
|
+
decoded
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Encode a code-owned page request. Model output only ever travels as a JSON value.
|
|
108
|
+
def payload(page_request)
|
|
109
|
+
op = page_request["op"] || page_request[:op]
|
|
110
|
+
raise ArgumentError, "A page request needs an operation" unless op.is_a?(String)
|
|
111
|
+
|
|
112
|
+
JSON.generate(page_request)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Ask the bridge to exit, then make sure the process is gone.
|
|
116
|
+
def close
|
|
117
|
+
@lock.synchronize do
|
|
118
|
+
return if @closed
|
|
119
|
+
return @closed = true unless @started
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
# Marked closed only after the request: a bridge must be allowed to answer its own exit.
|
|
123
|
+
request("exit", timeout: 2) if running?
|
|
124
|
+
rescue Error
|
|
125
|
+
nil # A bridge that will not be asked to leave is made to.
|
|
126
|
+
end
|
|
127
|
+
@closed = true
|
|
128
|
+
@stdin&.close unless @stdin&.closed?
|
|
129
|
+
return if @wait.join(2)
|
|
130
|
+
|
|
131
|
+
Process.kill("TERM", @wait.pid)
|
|
132
|
+
@wait.join(2) || Process.kill("KILL", @wait.pid)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def spawn_process
|
|
139
|
+
@stdin, stdout, stderr, @wait = Open3.popen3(*@command, @script)
|
|
140
|
+
@pid = @wait.pid
|
|
141
|
+
@stdin.sync = true
|
|
142
|
+
[@stdin, stdout, stderr].each { |io| io.set_encoding("UTF-8") }
|
|
143
|
+
@readers = [
|
|
144
|
+
Thread.new do
|
|
145
|
+
stdout.each_line do |line|
|
|
146
|
+
@lines << if line.bytesize > MAX_LINE_BYTES
|
|
147
|
+
OVERSIZED
|
|
148
|
+
else
|
|
149
|
+
begin
|
|
150
|
+
JSON.parse(line)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
INVALID
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
@lines << EOF
|
|
157
|
+
end,
|
|
158
|
+
Thread.new do
|
|
159
|
+
stderr.each_line { |line| @stderr.shift if @stderr.push(line.chomp).size > STDERR_LINES }
|
|
160
|
+
end
|
|
161
|
+
]
|
|
162
|
+
@readers.each { |thread| thread.abort_on_exception = false }
|
|
163
|
+
rescue SystemCallError => e
|
|
164
|
+
raise BridgeError, "Could not start osascript: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def receive(id, timeout)
|
|
168
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
169
|
+
loop do
|
|
170
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
171
|
+
raise BridgeTimeout, "Bridge request #{id} timed out" if remaining <= 0
|
|
172
|
+
|
|
173
|
+
message = @lines.pop(timeout: remaining)
|
|
174
|
+
raise BridgeTimeout, "Bridge request #{id} timed out" if message.nil?
|
|
175
|
+
raise BridgeError, "The bridge exited#{" (#{@stderr.last})" if @stderr.any?}" if message == EOF
|
|
176
|
+
raise BridgeError, "The bridge returned an oversized line" if message == OVERSIZED
|
|
177
|
+
raise BridgeError, "The bridge wrote invalid JSON" if message == INVALID
|
|
178
|
+
raise BridgeError, "The bridge returned a non-object response" unless message.is_a?(Hash)
|
|
179
|
+
# A late reply to an abandoned request is discarded rather than mistaken for this one.
|
|
180
|
+
next if message["id"].is_a?(Integer) && message["id"] < id
|
|
181
|
+
|
|
182
|
+
raise BridgeError, "The bridge returned an unexpected response id" unless message["id"] == id
|
|
183
|
+
|
|
184
|
+
return message
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def ensure_running
|
|
189
|
+
raise BridgeError, "Bridge is closed" if @closed
|
|
190
|
+
raise BridgeError, "Bridge is not started" unless @started
|
|
191
|
+
raise BridgeError, "Bridge exited with status #{@wait.value.exitstatus}" unless running?
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Wrangle
|
|
10
|
+
# Drives Safari through `safaridriver --mcp` instead of Apple Events.
|
|
11
|
+
#
|
|
12
|
+
# Safari 27 ships an MCP server over stdio, and its `evaluate_javascript` tool is enough to run the
|
|
13
|
+
# same `snapshot.js` and `page.js` the Apple Events bridge uses. So this is a transport swap, not a
|
|
14
|
+
# second implementation of the page protocol: the JavaScript, the epoch guard, and the action ids
|
|
15
|
+
# are identical, and only the pipe underneath changes.
|
|
16
|
+
#
|
|
17
|
+
# It is opt-in. The automation server drives its own isolated tab, which means it cannot see the
|
|
18
|
+
# windows you already have open — including whatever you are signed into. That isolation is the
|
|
19
|
+
# reason to want it and the reason it is not the default.
|
|
20
|
+
#
|
|
21
|
+
# Cost shape, measured on this machine: the first navigation pays about six seconds to bring the
|
|
22
|
+
# automation browser up, after which an act costs ~3 ms against Apple Events' ~120 ms. It wins on
|
|
23
|
+
# sessions of roughly thirty actions or more and loses on short ones.
|
|
24
|
+
class McpBridge
|
|
25
|
+
COMMAND = ["/usr/bin/safaridriver", "--mcp"].freeze
|
|
26
|
+
PROTOCOL = "2024-11-05"
|
|
27
|
+
SCRIPTS = File.expand_path("js", __dir__)
|
|
28
|
+
MISSING = "__wrangle_runtime_missing__"
|
|
29
|
+
# Ops that only read. A mutation is never re-dispatched, so only these may be retried after the
|
|
30
|
+
# runtime is reinstalled.
|
|
31
|
+
READ_ONLY = %w[install observe marker guard probe].freeze
|
|
32
|
+
|
|
33
|
+
def initialize(command: COMMAND, request_timeout: 30, startup_timeout: 25)
|
|
34
|
+
@command = command
|
|
35
|
+
@request_timeout = request_timeout
|
|
36
|
+
@startup_timeout = startup_timeout
|
|
37
|
+
@lock = Monitor.new
|
|
38
|
+
@next_id = 1
|
|
39
|
+
@started = false
|
|
40
|
+
@closed = false
|
|
41
|
+
@stderr = []
|
|
42
|
+
@handle = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def running? = !@io.nil? && !@io.closed? && !@closed
|
|
46
|
+
|
|
47
|
+
def stderr = @stderr.dup
|
|
48
|
+
|
|
49
|
+
# Bring the server up and handshake. Returns the Safari instance count the session guard expects;
|
|
50
|
+
# the automation browser is its own instance, so window ids are never ambiguous here.
|
|
51
|
+
def start
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
next 1 if @started
|
|
54
|
+
|
|
55
|
+
spawn_process
|
|
56
|
+
rpc("initialize", { protocolVersion: PROTOCOL, capabilities: {},
|
|
57
|
+
clientInfo: { name: "wrangle", version: Wrangle::VERSION } }, @startup_timeout)
|
|
58
|
+
notify("notifications/initialized")
|
|
59
|
+
@started = true
|
|
60
|
+
1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def request(op, timeout: nil, **params)
|
|
65
|
+
@lock.synchronize do
|
|
66
|
+
start unless @started
|
|
67
|
+
case op.to_s
|
|
68
|
+
when "ping" then { "ok" => true }
|
|
69
|
+
when "open" then open_tab(params, timeout)
|
|
70
|
+
when "close" then close_tab
|
|
71
|
+
when "windows" then { "windows" => tabs }
|
|
72
|
+
when "attach"
|
|
73
|
+
raise BridgeCallError.new("unsupported", "The MCP backend drives its own tab and cannot " \
|
|
74
|
+
"attach to a window you already have open.")
|
|
75
|
+
when "displays", "bounds"
|
|
76
|
+
raise BridgeCallError.new("unsupported", "The MCP backend has no window geometry; " \
|
|
77
|
+
"use the Apple Events backend for --display.")
|
|
78
|
+
else
|
|
79
|
+
raise BridgeCallError.new("unsupported", "The MCP backend does not support #{op.inspect}")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Run one code-owned page request and decode its JSON reply, exactly as the Apple Events bridge
|
|
85
|
+
# does. `verify` is unnecessary here: the JSON-RPC reply is itself the acknowledgement, so a
|
|
86
|
+
# request that returns at all was delivered.
|
|
87
|
+
def evaluate(scope, page_request, verify: false, timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
88
|
+
raise ArgumentError, "Bridge evaluation needs a scope and a request" unless scope.is_a?(Hash) &&
|
|
89
|
+
page_request.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
@lock.synchronize do
|
|
92
|
+
start unless @started
|
|
93
|
+
decode(dispatch(page_request, timeout))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def close
|
|
98
|
+
@lock.synchronize do
|
|
99
|
+
return if @closed
|
|
100
|
+
|
|
101
|
+
@closed = true
|
|
102
|
+
begin
|
|
103
|
+
close_tab if @handle
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
shutdown_process
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def dispatch(page_request, timeout)
|
|
114
|
+
op = (page_request["op"] || page_request[:op]).to_s
|
|
115
|
+
result = run(page_request, timeout)
|
|
116
|
+
return result unless result == MISSING
|
|
117
|
+
|
|
118
|
+
# The document was replaced, so the installed runtime went with it. Reinstalling is a read, but
|
|
119
|
+
# re-dispatching a mutation is not: an act whose runtime vanished is reported as a lost epoch and
|
|
120
|
+
# left for the session to resolve by reading it back.
|
|
121
|
+
install_runtime(timeout)
|
|
122
|
+
return JSON.generate({ status: "epoch_lost" }) unless READ_ONLY.include?(op)
|
|
123
|
+
|
|
124
|
+
result = run(page_request, timeout)
|
|
125
|
+
raise BridgeError, "The page runtime would not install" if result == MISSING
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The runtime is installed once per document; every later call ships only the request.
|
|
131
|
+
def run(page_request, timeout)
|
|
132
|
+
expression = "return window.__wrangleRun ? window.__wrangleRun(#{JSON.generate(page_request)}) " \
|
|
133
|
+
": #{JSON.generate(MISSING)}"
|
|
134
|
+
text = tool("evaluate_javascript", { expression: expression }, timeout)
|
|
135
|
+
parse_json(text, "page runtime")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def install_runtime(timeout)
|
|
139
|
+
snapshot = File.read(File.join(SCRIPTS, "snapshot.js"))
|
|
140
|
+
page = File.read(File.join(SCRIPTS, "page.js"))
|
|
141
|
+
# snapshot.js opens with comments, so it is parenthesised: `return` followed by a comment and a
|
|
142
|
+
# newline is `return;` under automatic semicolon insertion, which silently yields null.
|
|
143
|
+
expression = <<~JS
|
|
144
|
+
window.__wrangleSnapshot = () => (
|
|
145
|
+
#{snapshot}
|
|
146
|
+
);
|
|
147
|
+
window.__wranglePage = (#{page});
|
|
148
|
+
window.__wrangleRun = (request) => window.__wranglePage(request, window.__wrangleSnapshot);
|
|
149
|
+
return "installed";
|
|
150
|
+
JS
|
|
151
|
+
tool("evaluate_javascript", { expression: expression }, timeout)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def decode(result)
|
|
155
|
+
decoded = begin
|
|
156
|
+
JSON.parse(result)
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
raise BridgeError, "The page returned invalid JSON"
|
|
159
|
+
end
|
|
160
|
+
raise BridgeError, "The page returned an invalid result" unless decoded.is_a?(Hash) &&
|
|
161
|
+
decoded["status"].is_a?(String)
|
|
162
|
+
|
|
163
|
+
decoded
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def open_tab(params, timeout)
|
|
167
|
+
url = params[:url] || params["url"]
|
|
168
|
+
raise BridgeCallError.new("usage", "The MCP backend needs a URL to open") unless url.is_a?(String)
|
|
169
|
+
|
|
170
|
+
if params[:display] || params[:bounds]
|
|
171
|
+
raise BridgeCallError.new("unsupported", "The MCP backend places its own automation tab and " \
|
|
172
|
+
"cannot honour --display; use the default backend.")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
tool("navigate_to_url", { url: url }, timeout || @startup_timeout)
|
|
176
|
+
current = tabs.first || {}
|
|
177
|
+
@handle = current["handle"]
|
|
178
|
+
install_runtime(timeout)
|
|
179
|
+
{ "window_id" => 1, "tab_index" => 1, "url" => current["url"] || url, "bounds" => nil }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def close_tab
|
|
183
|
+
handle = @handle
|
|
184
|
+
@handle = nil
|
|
185
|
+
return { "closed" => false } unless handle
|
|
186
|
+
|
|
187
|
+
tool("close_tab", { handle: handle }, 10)
|
|
188
|
+
{ "closed" => true }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def tabs
|
|
192
|
+
listed = parse_json(tool("list_tabs", {}, 10), "tab list")
|
|
193
|
+
listed.is_a?(Array) ? listed : []
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def parse_json(text, what)
|
|
197
|
+
JSON.parse(text.to_s)
|
|
198
|
+
rescue JSON::ParserError
|
|
199
|
+
raise BridgeError, "The MCP server returned an unreadable #{what}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# One MCP tool call, unwrapped to its single text item.
|
|
203
|
+
def tool(name, arguments, timeout)
|
|
204
|
+
response = rpc("tools/call", { name: name, arguments: arguments }, timeout || @request_timeout)
|
|
205
|
+
content = response.dig("result", "content")
|
|
206
|
+
if response.dig("result", "isError") || !content.is_a?(Array)
|
|
207
|
+
raise BridgeCallError.new("tool_error", "Safari's #{name} tool failed: #{summarise(response)}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
item = content.first
|
|
211
|
+
raise BridgeError, "Safari's #{name} tool returned no text" unless item.is_a?(Hash) &&
|
|
212
|
+
item["text"].is_a?(String)
|
|
213
|
+
|
|
214
|
+
item["text"]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def summarise(response)
|
|
218
|
+
text = response.dig("result", "content", 0, "text") || response.dig("error", "message")
|
|
219
|
+
text.to_s[0, 200]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def rpc(method, params, timeout)
|
|
223
|
+
id = @next_id
|
|
224
|
+
@next_id += 1
|
|
225
|
+
write(JSON.generate({ jsonrpc: "2.0", id: id, method: method, params: params }))
|
|
226
|
+
response = receive(id, timeout)
|
|
227
|
+
if (error = response["error"])
|
|
228
|
+
raise BridgeCallError.new("mcp_error", "The MCP server refused #{method}: #{error["message"]}")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
response
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def notify(method, params = {})
|
|
235
|
+
write(JSON.generate({ jsonrpc: "2.0", method: method, params: params }))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def write(line)
|
|
239
|
+
raise BridgeError, "The MCP bridge is no longer accepting requests" unless running?
|
|
240
|
+
|
|
241
|
+
@io.write("#{line}\n")
|
|
242
|
+
@io.flush
|
|
243
|
+
rescue Errno::EPIPE, IOError
|
|
244
|
+
raise BridgeError, "The MCP bridge is no longer accepting requests"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Replies are read until the matching id arrives; notifications from the server are discarded.
|
|
248
|
+
def receive(id, timeout)
|
|
249
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
250
|
+
loop do
|
|
251
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
252
|
+
raise BridgeError, "The MCP server did not reply in time" if remaining <= 0
|
|
253
|
+
|
|
254
|
+
line = begin
|
|
255
|
+
Timeout.timeout(remaining) { @io.gets }
|
|
256
|
+
rescue Timeout::Error
|
|
257
|
+
raise BridgeError, "The MCP server did not reply in time"
|
|
258
|
+
end
|
|
259
|
+
raise BridgeError, "The MCP server closed the connection" if line.nil?
|
|
260
|
+
|
|
261
|
+
message = begin
|
|
262
|
+
JSON.parse(line)
|
|
263
|
+
rescue JSON::ParserError
|
|
264
|
+
next
|
|
265
|
+
end
|
|
266
|
+
return message if message.is_a?(Hash) && message["id"] == id
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def spawn_process
|
|
271
|
+
@stderr_read, stderr_write = IO.pipe
|
|
272
|
+
@io = IO.popen(@command, "r+", err: stderr_write)
|
|
273
|
+
stderr_write.close
|
|
274
|
+
@stderr_thread = Thread.new do
|
|
275
|
+
while (line = @stderr_read.gets)
|
|
276
|
+
@stderr.shift if @stderr.length >= 50
|
|
277
|
+
@stderr << line.chomp
|
|
278
|
+
end
|
|
279
|
+
rescue IOError
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
rescue Errno::ENOENT
|
|
283
|
+
raise BridgeError, "safaridriver is not available; the MCP backend needs Safari 27 or newer"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def shutdown_process
|
|
287
|
+
@io&.close
|
|
288
|
+
rescue IOError
|
|
289
|
+
nil
|
|
290
|
+
ensure
|
|
291
|
+
begin
|
|
292
|
+
@stderr_read&.close
|
|
293
|
+
rescue IOError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
@stderr_thread&.kill
|
|
297
|
+
@io = nil
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Wrangle
|
|
7
|
+
# Turning an observation into something a decision can be checked against.
|
|
8
|
+
module Observation
|
|
9
|
+
# The fields that decide whether a page is "the same page" for the purpose of a decision.
|
|
10
|
+
FINGERPRINTED = %w[url text actions scroll].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def fingerprint(state)
|
|
15
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(state.slice(*FINGERPRINTED))))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Deterministic ordering, so an unchanged page always hashes the same way.
|
|
19
|
+
def canonical(value)
|
|
20
|
+
case value
|
|
21
|
+
when Hash then value.keys.sort.to_h { |key| [key.to_s, canonical(value[key])] }
|
|
22
|
+
when Array then value.map { |element| canonical(element) }
|
|
23
|
+
else value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return the one observed candidate this action refers to, or refuse to act at all.
|
|
28
|
+
#
|
|
29
|
+
# This is the boundary that keeps model output from becoming instructions: a decision may only
|
|
30
|
+
# name an action the page was just observed to offer, and it must match that candidate exactly.
|
|
31
|
+
def require_observed(action, page)
|
|
32
|
+
raise ArgumentError, "Actions require an observed page and action" unless action.is_a?(Hash) && page.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
id = action["id"] || action[:id]
|
|
35
|
+
candidates = page["actions"]
|
|
36
|
+
raise ArgumentError, "Action is not bound to an observation" unless id.is_a?(String) && candidates.is_a?(Array)
|
|
37
|
+
|
|
38
|
+
matches = candidates.select { |candidate| candidate.is_a?(Hash) && candidate["id"] == id }
|
|
39
|
+
unless matches.one? && matches.first == stringify(action)
|
|
40
|
+
raise ArgumentError, "Action is not an exact candidate from the observed page"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
matches.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stringify(action)
|
|
47
|
+
action.to_h { |key, value| [key.to_s, value] }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|