@pa1nd/horse-browser 0.4.0 → 0.5.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.
- package/SKILL.md +1 -1
- package/agent-helpers.py +50 -3
- package/extension/background.js +39 -0
- package/extension/manifest.json +18 -4
- package/extension/realchrome.js +27 -0
- package/extension/rules.json +23 -0
- package/install.sh +53 -14
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -78,7 +78,7 @@ self.listTabs(label: string) -> Promise<Tab[]>
|
|
|
78
78
|
// }
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
**`bh_open` / `bh_list` / `bh_switch_tab` are pre-installed.** `install.sh` writes them
|
|
81
|
+
**`bh_open` / `bh_list` / `bh_switch_tab` are pre-installed.** `install.sh` writes them to browser-harness's `agent-workspace/horse_helpers.py`, loaded on every call via a small stub it adds once to `agent_helpers.py` (your own additions to that file are never touched), so they're available immediately — just call `bh_open(url)`.
|
|
82
82
|
|
|
83
83
|
If `bh_open` is somehow undefined (a browser-harness checkout that never ran our `install.sh`), re-run `horse-browser`'s `install.sh` to install them — don't hand-roll your own; the focus-safe behaviour is subtle. You pass CDP `targetId`s only — the extension bridges to chrome `tabId`s internally.
|
|
84
84
|
|
package/agent-helpers.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# horse-browser helpers for browser-harness.
|
|
2
2
|
#
|
|
3
|
-
# install.sh
|
|
4
|
-
# (which auto-loads on every
|
|
5
|
-
# the first run — no agent has to install the
|
|
3
|
+
# install.sh installs this file as <workspace>/horse_helpers.py and appends a
|
|
4
|
+
# load-once stub to agent_helpers.py (which browser-harness auto-loads on every
|
|
5
|
+
# call), so bh_open() is available on the first run — no agent has to install the
|
|
6
|
+
# recipe by hand. Updates overwrite horse_helpers.py only; anything a user keeps
|
|
7
|
+
# in agent_helpers.py itself is never touched.
|
|
6
8
|
#
|
|
7
9
|
# What they give you (pass CDP targetIds; the extension bridges to chrome tabIds):
|
|
8
10
|
# bh_open(url) open a tab WITHOUT raising the browser over your macOS app,
|
|
@@ -213,3 +215,48 @@ def bh_open(url):
|
|
|
213
215
|
|
|
214
216
|
def bh_list():
|
|
215
217
|
return ext_call("listTabs", _session_id()) or [] if _session_id() else []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── screenshots: a unique file per call ──────────────────────────────────────────
|
|
221
|
+
# Stock capture_screenshot() defaults every call — from EVERY session's daemon — to
|
|
222
|
+
# the ONE shared file <tmp>/shot.png. With concurrent sessions, a neighbour overwrites
|
|
223
|
+
# it between this session writing the shot and the agent Reading the path it printed,
|
|
224
|
+
# so the "screenshot" shows another session's tab. Same namespace-merge trick as cdp()
|
|
225
|
+
# above: defining capture_screenshot here replaces the stock one everywhere. Each call
|
|
226
|
+
# mints its own file (named after the session's daemon lane) and returns that path, so
|
|
227
|
+
# the agent's existing print-the-path-then-Read flow just works.
|
|
228
|
+
from browser_harness.helpers import capture_screenshot as _real_capture_screenshot
|
|
229
|
+
from browser_harness import _ipc as _bh_ipc
|
|
230
|
+
|
|
231
|
+
_hb_shots_swept = False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _hb_sweep_shots(max_age_s=86400):
|
|
235
|
+
# Unique names accumulate where the single shot.png didn't — drop day-old ones.
|
|
236
|
+
# Once per process, and only in processes that actually screenshot.
|
|
237
|
+
global _hb_shots_swept
|
|
238
|
+
if _hb_shots_swept:
|
|
239
|
+
return
|
|
240
|
+
_hb_shots_swept = True
|
|
241
|
+
import time
|
|
242
|
+
now = time.time()
|
|
243
|
+
try:
|
|
244
|
+
for e in os.scandir(_bh_ipc._TMP):
|
|
245
|
+
if e.name.startswith("shot-") and e.name.endswith(".png") and now - e.stat().st_mtime > max_age_s:
|
|
246
|
+
try: os.remove(e.path)
|
|
247
|
+
except OSError: pass
|
|
248
|
+
except OSError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def capture_screenshot(path=None, full=False, max_dim=None):
|
|
253
|
+
"""Save a PNG of the current viewport to a per-call unique file; returns the path.
|
|
254
|
+
Args as stock browser-harness: full=capture beyond viewport, max_dim=downscale."""
|
|
255
|
+
if path is None:
|
|
256
|
+
_hb_sweep_shots()
|
|
257
|
+
import tempfile
|
|
258
|
+
fd, path = tempfile.mkstemp(
|
|
259
|
+
prefix=f"shot-{os.environ.get('BU_NAME', 'default')}-",
|
|
260
|
+
suffix=".png", dir=str(_bh_ipc._TMP))
|
|
261
|
+
os.close(fd)
|
|
262
|
+
return _real_capture_screenshot(path, full=full, max_dim=max_dim)
|
package/extension/background.js
CHANGED
|
@@ -137,3 +137,42 @@ chrome.runtime.onInstalled.addListener((details) => {
|
|
|
137
137
|
// First run on a fresh profile only: open the welcome page (not on updates/restarts).
|
|
138
138
|
if (details.reason === "install") chrome.tabs.create({ url: chrome.runtime.getURL("hello.html"), active: true });
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
// ── Passive network monitor ── Tier 1 realness debug aid. chrome.webRequest observes every
|
|
142
|
+
// request WITHOUT touching the page — no window.fetch/XHR wrapper, which would itself be a
|
|
143
|
+
// fingerprint tell (a non-native fetch.toString). We buffer the last N *outcomes* per tab
|
|
144
|
+
// (onCompleted carries the status; onErrorOccurred carries blocks/aborts), so an agent can
|
|
145
|
+
// see what a click actually fired — decisive for spotting silent gating. State lives in the
|
|
146
|
+
// SW's memory: the 30s keepalive alarm keeps it warm during a session, but a hard SW
|
|
147
|
+
// eviction resets it — fine for a read-right-after-you-act ring buffer.
|
|
148
|
+
const NETLOG = new Map(); // tabId -> [{ t, method, url, type, status, fromCache, error }]
|
|
149
|
+
const NETLOG_MAX = 200;
|
|
150
|
+
function netPush(tabId, entry) {
|
|
151
|
+
if (tabId == null || tabId < 0) return; // -1 = not tied to a tab (SW/extension fetches)
|
|
152
|
+
let buf = NETLOG.get(tabId);
|
|
153
|
+
if (!buf) { buf = []; NETLOG.set(tabId, buf); }
|
|
154
|
+
buf.push(entry);
|
|
155
|
+
if (buf.length > NETLOG_MAX) buf.shift();
|
|
156
|
+
}
|
|
157
|
+
const NET_URLS = { urls: ["http://*/*", "https://*/*"] };
|
|
158
|
+
chrome.webRequest.onCompleted.addListener(
|
|
159
|
+
(d) => netPush(d.tabId, { t: d.timeStamp, method: d.method, url: d.url, type: d.type, status: d.statusCode, fromCache: d.fromCache }),
|
|
160
|
+
NET_URLS,
|
|
161
|
+
);
|
|
162
|
+
chrome.webRequest.onErrorOccurred.addListener(
|
|
163
|
+
(d) => netPush(d.tabId, { t: d.timeStamp, method: d.method, url: d.url, type: d.type, status: null, error: d.error }),
|
|
164
|
+
NET_URLS,
|
|
165
|
+
);
|
|
166
|
+
chrome.tabs.onRemoved.addListener((tabId) => NETLOG.delete(tabId)); // don't leak buffers
|
|
167
|
+
|
|
168
|
+
// getNetLog(targetId) / clearNetLog(targetId) — CDP-callable like groupTab/listTabs. Agents
|
|
169
|
+
// pass a CDP targetId; we bridge to the chrome tabId the same way the grouper does.
|
|
170
|
+
self.getNetLog = async (targetId) => {
|
|
171
|
+
const tabId = await tabIdForTargetId(targetId);
|
|
172
|
+
return NETLOG.get(tabId) || [];
|
|
173
|
+
};
|
|
174
|
+
self.clearNetLog = async (targetId) => {
|
|
175
|
+
const tabId = await tabIdForTargetId(targetId);
|
|
176
|
+
NETLOG.delete(tabId);
|
|
177
|
+
return true;
|
|
178
|
+
};
|
package/extension/manifest.json
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Agent Tab Grouper",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Groups CDP-driven automation tabs into a per-session tab group
|
|
4
|
+
"version": "0.6.0",
|
|
5
|
+
"description": "Groups CDP-driven automation tabs into a per-session tab group and serves the Agent Monitor (a live CCTV grid). Also carries Tier 1 realness: an always-on Chrome-real fingerprint (UA-CH + sec-ch-ua) and a passive per-tab network log. Driven over CDP via Runtime.evaluate against this extension's service worker.",
|
|
6
6
|
"icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" },
|
|
7
7
|
"background": { "service_worker": "background.js" },
|
|
8
|
+
"content_scripts": [
|
|
9
|
+
{
|
|
10
|
+
"matches": ["http://*/*", "https://*/*"],
|
|
11
|
+
"js": ["realchrome.js"],
|
|
12
|
+
"run_at": "document_start",
|
|
13
|
+
"world": "MAIN",
|
|
14
|
+
"all_frames": true
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"declarative_net_request": {
|
|
18
|
+
"rule_resources": [
|
|
19
|
+
{ "id": "realness_headers", "enabled": true, "path": "rules.json" }
|
|
20
|
+
]
|
|
21
|
+
},
|
|
8
22
|
"action": {
|
|
9
23
|
"default_title": "Open Agent Monitor",
|
|
10
24
|
"default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
|
|
11
25
|
},
|
|
12
|
-
"permissions": ["tabs", "tabGroups", "debugger", "alarms"],
|
|
13
|
-
"host_permissions": ["http
|
|
26
|
+
"permissions": ["tabs", "tabGroups", "debugger", "alarms", "webRequest", "declarativeNetRequest"],
|
|
27
|
+
"host_permissions": ["http://*/*", "https://*/*"]
|
|
14
28
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Tier 1 realness — the JS half of the Chrome-for-Testing → stable Google Chrome mask.
|
|
2
|
+
// Runs in the page's MAIN world at document_start (see manifest content_scripts), so it
|
|
3
|
+
// patches navigator.userAgentData BEFORE the page's first script reads it (verified: a
|
|
4
|
+
// parse-time read already sees "Google Chrome", 10/10). The wire half — the sec-ch-ua
|
|
5
|
+
// request header — is done coherently by declarativeNetRequest (rules.json), so JS and
|
|
6
|
+
// headers agree. Payload lifted verbatim from the proven real_chrome() helper.
|
|
7
|
+
(function(){ 'use strict'; try{
|
|
8
|
+
var u = navigator.userAgentData; if(!u) return;
|
|
9
|
+
var FULL='150.0.7871.47', MAJOR='150';
|
|
10
|
+
function low(){ return [{brand:'Not;A=Brand',version:'8'},{brand:'Chromium',version:MAJOR},{brand:'Google Chrome',version:MAJOR}]; }
|
|
11
|
+
function full(){ return [{brand:'Not;A=Brand',version:'8.0.0.0'},{brand:'Chromium',version:FULL},{brand:'Google Chrome',version:FULL}]; }
|
|
12
|
+
var proto = Object.getPrototypeOf(u);
|
|
13
|
+
function nativeProxy(orig, impl){ return new Proxy(orig, { apply:function(t,ta,a){ return impl(t,ta,a); } }); }
|
|
14
|
+
var bd = Object.getOwnPropertyDescriptor(proto,'brands');
|
|
15
|
+
if(bd && bd.get && !u.brands.some(function(b){return b.brand==='Google Chrome'})){
|
|
16
|
+
Object.defineProperty(proto,'brands',{ get: nativeProxy(bd.get, function(){ return low(); }), set:undefined, enumerable:bd.enumerable, configurable:true });
|
|
17
|
+
}
|
|
18
|
+
if(typeof proto.getHighEntropyValues==='function'){
|
|
19
|
+
proto.getHighEntropyValues = nativeProxy(proto.getHighEntropyValues, function(t,ta,a){
|
|
20
|
+
return Reflect.apply(t,ta,a).then(function(r){ if(r&&typeof r==='object'){ if('brands' in r) r.brands=low(); if('fullVersionList' in r) r.fullVersionList=full(); if('uaFullVersion' in r) r.uaFullVersion=FULL; } return r; });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
if(typeof proto.toJSON==='function'){
|
|
24
|
+
proto.toJSON = nativeProxy(proto.toJSON, function(t,ta,a){ var o=Reflect.apply(t,ta,a); if(o&&typeof o==='object') o.brands=low(); return o; });
|
|
25
|
+
}
|
|
26
|
+
try{ if(navigator.webdriver===true) Object.defineProperty(Navigator.prototype,'webdriver',{get:function(){return false},configurable:true}); }catch(e){}
|
|
27
|
+
}catch(e){} })();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": 1,
|
|
4
|
+
"priority": 1,
|
|
5
|
+
"action": {
|
|
6
|
+
"type": "modifyHeaders",
|
|
7
|
+
"requestHeaders": [
|
|
8
|
+
{
|
|
9
|
+
"header": "sec-ch-ua",
|
|
10
|
+
"operation": "set",
|
|
11
|
+
"value": "\"Not;A=Brand\";v=\"8\", \"Chromium\";v=\"150\", \"Google Chrome\";v=\"150\""
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"condition": {
|
|
16
|
+
"urlFilter": "*",
|
|
17
|
+
"resourceTypes": [
|
|
18
|
+
"main_frame", "sub_frame", "stylesheet", "script", "image",
|
|
19
|
+
"font", "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket", "other"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
package/install.sh
CHANGED
|
@@ -64,12 +64,14 @@ else
|
|
|
64
64
|
fi
|
|
65
65
|
|
|
66
66
|
# 3. bh_open helpers into browser-harness ───────────────────────────────────────
|
|
67
|
-
# browser-harness auto-loads <workspace>/agent_helpers.py on every call
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
67
|
+
# browser-harness auto-loads <workspace>/agent_helpers.py on every call. Our helpers
|
|
68
|
+
# ship as horse_helpers.py — a file THIS installer owns and overwrites outright on
|
|
69
|
+
# every sync — plus a load-once stub appended to agent_helpers.py exactly once and
|
|
70
|
+
# never rewritten, so anything a user adds to agent_helpers.py is never touched.
|
|
71
|
+
# The workspace location varies by version/install (≤0.1.0: <repo>/agent-workspace;
|
|
72
|
+
# 0.1.1+: ~/.config/browser-harness/agent-workspace; or whatever BH_AGENT_WORKSPACE
|
|
73
|
+
# points at) — so instead of guessing, we ASK browser-harness itself where it loads
|
|
74
|
+
# from via its own python (helpers.AGENT_WORKSPACE).
|
|
73
75
|
if ! command -v browser-harness >/dev/null 2>&1; then
|
|
74
76
|
# browser-harness is a separate (Python) prerequisite. In a hand/curl install it's
|
|
75
77
|
# required up front. Under npm it's a *declared* prereq the user may not have yet — so
|
|
@@ -82,17 +84,54 @@ if ! command -v browser-harness >/dev/null 2>&1; then
|
|
|
82
84
|
exit 1
|
|
83
85
|
fi
|
|
84
86
|
HELPERS_SRC="$HERE/agent-helpers.py"
|
|
87
|
+
# Legacy marker: pre-0.4.1 installs appended the helpers INLINE under this line, and
|
|
88
|
+
# re-syncs replaced marker→EOF — silently eating anything a user had added below the
|
|
89
|
+
# block. Kept only so those files can be migrated once.
|
|
85
90
|
HELPERS_MARKER="# ── horse-browser helpers (installed by horse-browser/install.sh) ──"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
LOADER_MARKER="# >>> horse-browser: bh_open helpers (managed loader — do not edit) >>>"
|
|
92
|
+
install_helpers_into() { # $1 = workspace dir; (re)syncs the helpers — idempotent, so
|
|
93
|
+
local ws="$1" dst="$1/agent_helpers.py" # re-running install.sh deploys helper UPDATES too.
|
|
94
|
+
mkdir -p "$ws" 2>/dev/null || return 0
|
|
95
|
+
cp "$HELPERS_SRC" "$ws/horse_helpers.py"
|
|
96
|
+
# one-time migration of a legacy inline block: strip it ONLY on an exact byte match
|
|
97
|
+
# with the shipped source, so user additions below it survive. A modified/unknown
|
|
98
|
+
# block is left in place — harmless, the loader runs after it and its defs win.
|
|
90
99
|
if [ -f "$dst" ] && grep -qF "$HELPERS_MARKER" "$dst"; then
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
python3 - "$dst" "$HELPERS_SRC" "$HELPERS_MARKER" <<'PY' || true
|
|
101
|
+
import sys
|
|
102
|
+
dst, src, marker = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
103
|
+
text, block = open(dst).read(), open(src).read()
|
|
104
|
+
i = text.find(marker)
|
|
105
|
+
legacy = marker + "\n" + block
|
|
106
|
+
if i >= 0 and text[i:].startswith(legacy):
|
|
107
|
+
head = text[:i].rstrip("\n")
|
|
108
|
+
rest = text[i + len(legacy):].lstrip("\n")
|
|
109
|
+
parts = [p for p in (head, rest) if p]
|
|
110
|
+
open(dst, "w").write("\n\n".join(parts) + ("\n" if parts else ""))
|
|
111
|
+
print(" migrated: legacy inline helper block removed (everything else kept)")
|
|
112
|
+
elif i >= 0:
|
|
113
|
+
print(" note: a legacy horse-browser block is present but doesn't match the shipped", file=sys.stderr)
|
|
114
|
+
print(" source — left in place (the horse_helpers.py loader below supersedes", file=sys.stderr)
|
|
115
|
+
print(" it); remove it from " + dst + " by hand when convenient.", file=sys.stderr)
|
|
116
|
+
PY
|
|
117
|
+
fi
|
|
118
|
+
if ! grep -qF "$LOADER_MARKER" "$dst" 2>/dev/null; then
|
|
119
|
+
if [ -s "$dst" ]; then printf '\n' >> "$dst"; fi
|
|
120
|
+
printf '%s\n' "$LOADER_MARKER" >> "$dst"
|
|
121
|
+
cat >> "$dst" <<'LOADER'
|
|
122
|
+
# The helpers live in horse_helpers.py next to this file. install.sh overwrites THAT
|
|
123
|
+
# file on every sync and never rewrites this one — your own code here is safe.
|
|
124
|
+
try:
|
|
125
|
+
import os as _hb_os
|
|
126
|
+
_hb_path = _hb_os.path.join(_hb_os.path.dirname(_hb_os.path.abspath(__file__)), "horse_helpers.py")
|
|
127
|
+
exec(compile(open(_hb_path).read(), _hb_path, "exec"))
|
|
128
|
+
except Exception as _hb_err:
|
|
129
|
+
import sys as _hb_sys
|
|
130
|
+
print("horse-browser: couldn't load horse_helpers.py (%r) — re-run horse-browser's install.sh" % (_hb_err,), file=_hb_sys.stderr)
|
|
131
|
+
# <<< horse-browser: bh_open helpers <<<
|
|
132
|
+
LOADER
|
|
93
133
|
fi
|
|
94
|
-
|
|
95
|
-
echo "✓ synced bh_open helpers → $dst"
|
|
134
|
+
echo "✓ synced bh_open helpers → $ws/horse_helpers.py (loaded via stub in $dst)"
|
|
96
135
|
}
|
|
97
136
|
# (a) the workspace browser-harness ACTUALLY loads from — ask it via its own python
|
|
98
137
|
# (the CLI's shebang points at it); helpers.AGENT_WORKSPACE honours BH_AGENT_WORKSPACE and
|